Author SHA1 Message Date
Hermes CI Fix b736e0727a fix(strategy-map): 战略地图三处 P1 缺陷(D1 完备度路由 422 / D2 连线删除不落库 / D3 窄屏拖拽失效)
D1 GET /api/cma/maps/completeness/objectives 恒 422:真抢占者是先注册的
   objectives.router GET /{map_id}/objectives(map_id 被当 int 解析)。把
   map_completeness / map_summary 两个字面路径路由器前移到 objectives 之前,
   不动 prefix、不删参数化路由。新增同前缀族全量巡检用例。

D2 DELETE /{id}/connections 返回 200 不落库:canvas_data 为裸 Column(JSON),
   就地/换列表赋值不被 ORM 判脏。模型改 MutableDict.as_mutable(JSON)(不改 DDL),
   add_connection / delete_connection_by_key 均显式 flag_modified 双保险。

D3 窄视口 .kp-panel(fixed/z-1000) 遮画布,onTreeDragOver 用 elementFromPoint
   取节点恒 null ⇒ 拖拽建连线失效。改为遍历画布内 [data-node-key] 做 rect 包含
   判定、多点取中心最近者,不再回退 elementFromPoint。

测试:新增 backend/tests/test_map_route_shadowing_20261020.py(路由解析断言到
endpoint.__module__)与 test_map_connection_persistence_20261020.py(新 session
原生 SQL 复读 + 负测)。零生产写、零上线。
2026-10-21 02:04:28 +08:00
wecom-fullstack 86e24c6310 fix(strategy-map): 移除悬空 import kpi_target_reader(该模块全仓不存在,致 app.main 导入失败/部署自检中止)→ 目标值取 target_monthly or target_value
ci/woodpecker/push/main Pipeline was successful
2026-10-20 13:01:02 +08:00
wecom-fullstack ecbe55c48a merge: origin/main 合入战略地图改造分支(解 MapCanvas 模板冲突:摘要条+编辑条+口径条并存)
ci/woodpecker/push/main Pipeline was successful
2026-10-20 11:34:29 +08:00
wecom-fullstack c030fb7d18 fix(strategy-map): 口径违禁串代码级清零(9,492.6/83.9%/19/19/el-dialog 仅存于注释)+ components.d.ts 补 MapDimensionFilter 2026-10-20 11:33:58 +08:00
Hermes CI Fix 53cad7eaa9 merge: origin/main (A3 三维切片) 合入战略地图改造分支 2026-10-19 14:32:33 +08:00
Hermes CI Fix a8141e8879 wip(strategy-map): v4 口径落码(分线营收/新式毛利率/管理指标/C1 hold/57 归档) 2026-10-19 14:32:08 +08:00
Hermes CI Fix 4d20c27de4 wip(strategy-map): V1+V3 合版 + v2/v3 口径(完备度/C1 hold/57 归档组) 2026-10-19 10:33:40 +08:00
20 changed files with 2829 additions and 92 deletions
+179
View File
@@ -0,0 +1,179 @@
"""战略地图 · 数据完备度 API(口径唯一真源:CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
主口径(对外唯一主数):仅地图 65(已发布)15/19 = 78.9%,判据 = 被 23-KPI 集挂接。
附注口径(只作附注):全量 4 图 30/31 = 96.8%,判据 = 有KPI挂接(含归档图)。
KPI 级:active 行 117(e1=73 / e2=44),按行计,与目标级分列、不相除。
所有汇总数一律运行时对库内数据现算;判据数据(23-KPI 挂接关系)见
app/data/caliber_map_completeness.py。两种禁用组合(仅地图65且按 kpis 非空、全量31且按命中 23-KPI 集)在本模块无生成路径。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import caliber_map_completeness as cal
from app.database import get_db
from app.deps import get_entity_id
from app.models import KPIDefinition, MapObjective, StrategicMap
router = APIRouter(
prefix="/api/cma/maps/completeness",
tags=["战略地图数据完备度"],
dependencies=[Depends(require_auth)],
)
def _pct(n: int, d: int) -> Optional[float]:
"""占比只保留 1 位小数;分母为 0 返回 None(展示为「无数据」)。"""
if not d:
return None
return round(n * 100.0 / d, 1)
def _entity_scopes(db: Session, entity_id: int):
"""返回(主口径地图, 同属主的地图列表)。主口径地图仅认 map_id=65 且属于该实体。"""
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).all()
primary = next((m for m in maps if m.id == cal.PRIMARY_MAP_ID), None)
return primary, maps
def _objective_rows(db: Session, maps):
"""拉取给定地图的战略目标,并逐行标记 23-KPI 挂接 / 是否有 KPI。"""
map_ids = [m.id for m in maps]
if not map_ids:
return [], {}
title_by_map = {m.id: m.title for m in maps}
status_by_map = {m.id: (m.status or "") for m in maps}
objs = (
db.query(MapObjective)
.filter(MapObjective.map_id.in_(map_ids))
.order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id)
.all()
)
rows = []
for o in objs:
kpis = o.kpis if isinstance(o.kpis, list) else []
rows.append({
"objective_id": o.id,
"map_id": o.map_id,
"map_title": title_by_map.get(o.map_id),
"map_status": status_by_map.get(o.map_id),
"layer": o.dimension_key,
"layer_label": cal.LAYER_LABELS.get(o.dimension_key, o.dimension_key),
"name": o.name,
"kpis": kpis,
"has_kpi": len(kpis) > 0,
"covered_23kpi": o.id in cal.LINKED_OBJECTIVES,
})
return rows, title_by_map
@router.get("/summary")
def completeness_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""数据完备度总览:目标级(主数 + 附注)与 KPI 级(117 行)两层分列。"""
primary_map, maps = _entity_scopes(db, entity_id)
rows, _ = _objective_rows(db, maps)
# ---- 目标级 · 主口径:仅地图 65,判据 = 被 23-KPI 集挂接 ----
if primary_map is not None:
main_rows = [r for r in rows if r["map_id"] == cal.PRIMARY_MAP_ID]
main_total = len(main_rows)
main_covered = sum(1 for r in main_rows if r["covered_23kpi"])
primary = {
"available": True,
"caliber": cal.PRIMARY_CALIBER,
"label": cal.PRIMARY_LABEL,
"map_id": primary_map.id,
"map_title": primary_map.title,
"map_status": primary_map.status,
"covered": main_covered,
"total": main_total,
"pct": _pct(main_covered, main_total),
"display": f"{main_covered}/{main_total}",
}
else:
primary = {
"available": False,
"caliber": cal.PRIMARY_CALIBER,
"label": cal.PRIMARY_LABEL,
"map_id": cal.PRIMARY_MAP_ID,
"covered": None,
"total": None,
"pct": None,
"display": "—",
}
# ---- 目标级 · 附注口径:全量 4 图,判据 = 有 KPI 挂接 ----
# 判据复现(definition 件 §5 机读件 20261018-c1-spec/map-objectives-dump.csv 逐行):
# 23KPI 覆盖 26 + 非23KPI 但 map_objectives.kpis 非空 4 + 缺口 1 = 30/31。
# 即「被 23-KPI 集挂接」与「kpis 非空」取并集;与主口径判据(仅 23-KPI 挂接)不是同一判据。
# 附注与主数同属地图 65 的账套(全量 4 图);无地图 65 的账套不适用,展示「—」而非 0。
footnote_total = len(rows)
footnote_covered = sum(1 for r in rows if r["covered_23kpi"] or r["has_kpi"])
footnote_available = primary_map is not None and bool(rows)
footnote = {
"available": footnote_available,
"caliber": cal.FOOTNOTE_CALIBER,
"label": cal.FOOTNOTE_LABEL,
"covered": footnote_covered if footnote_available else None,
"total": footnote_total if footnote_available else None,
"pct": _pct(footnote_covered, footnote_total) if footnote_available else None,
"display": f"{footnote_covered}/{footnote_total}" if footnote_available else "—",
}
# ---- KPI 级:active 按行(e1=73 / e2=44,合计 117;去重编码 102 另册)----
kpi_rows = (
db.query(KPIDefinition)
.filter(KPIDefinition.status == "active")
.all()
)
by_entity: dict = {}
codes = set()
for k in kpi_rows:
by_entity[k.entity_id] = by_entity.get(k.entity_id, 0) + 1
codes.add(k.kpi_code)
kpi_level = {
"caliber": cal.KPI_CALIBER,
"label": cal.KPI_LABEL,
"total": len(kpi_rows),
"by_entity": [
{"entity_id": eid, "count": cnt} for eid, cnt in sorted(by_entity.items())
],
"distinct_codes": len(codes),
"note": "按 kpi_code 去重为独立口径,不与行数相除或换算",
}
return {
"caliber_id": cal.CALIBER_ID,
"caliber_version": cal.CALIBER_VERSION,
"entity_id": entity_id,
"applicable": primary_map is not None,
"scope_note": "主口径仅覆盖地图 65(已发布);附注口径含归档图,判据不同,不得互换算",
"objective_level": {"primary": primary, "footnote": footnote},
"kpi_level": kpi_level,
}
@router.get("/objectives")
def completeness_objectives(
map_id: Optional[int] = None,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""数据完备度清单(目标级逐行):供空态清单与补齐排期使用。"""
_, maps = _entity_scopes(db, entity_id)
if map_id is not None:
maps = [m for m in maps if m.id == map_id]
rows, _ = _objective_rows(db, maps)
return {
"caliber_id": cal.CALIBER_ID,
"caliber_version": cal.CALIBER_VERSION,
"entity_id": entity_id,
"caliber": cal.FOOTNOTE_CALIBER,
"rows": rows,
}
+238
View File
@@ -0,0 +1,238 @@
"""战略地图 · KPI 摘要接口(真数据 · 口径 CMA-A1 终稿,2026-10-19)
口径唯一真源:app/data/revenue_caliber.py(含科目映射与禁写清单)
本模块**不存任何金额常量**:全部对 voucher_details 现场聚合。
取数铁律:
1. 必经 `summary NOT LIKE '%结转%'` —— 否则损益双向抵销,收入恒为 0(历史踩坑);
2. 实体隔离:只取 token 绑定的 entity_id,不跨账套;
3. 无数据 ⇒ null + missing_reason,**不得兜底造数**;
4. 分业务线分列;禁净额;禁合并成单一「营业收入」(合计仅作可复算校验位)。
历史期缺同票折扣发票(四件套 0/4)⇒ 金额带 `data_status="estimated"` +
`data_tag="测算/待票"`,不得静默按新式重算冒充实账。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import revenue_caliber as rc
from app.database import get_db
from app.deps import get_entity_id
router = APIRouter(
prefix="/api/cma/maps/summary",
tags=["战略地图摘要"],
dependencies=[Depends(require_auth)],
)
MONTH_RE = "^[0-9]{4}-[0-9]{2}$"
NOT_CARRY = "summary NOT LIKE '%结转%'"
def _wan(v) -> Optional[float]:
"""元 → 万元(2 位),None 透传。"""
if v is None:
return None
return round(float(v) / 10000.0, 2)
def _period_window(db: Session, entity_id: int):
"""取该实体最新有凭证的会计月,窗口 = 当年 01 ~ 最新月(不写死日期)。"""
latest = db.execute(text(
f"SELECT MAX(period) FROM voucher_details "
f"WHERE entity_id = :e AND period REGEXP :re AND {NOT_CARRY}"
), {"e": entity_id, "re": MONTH_RE}).scalar()
if not latest:
return None, None, None
year = str(latest)[:4]
return f"{year}-01", str(latest), year
def _sum(db: Session, sql: str, params: dict):
return db.execute(text(sql), params).scalar()
@router.get("")
def map_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""摘要条数据:分业务线营业收入 / 毛利率(新式)/ 管理指标 / 口径切换标识。"""
cfg = rc.SUBJECT_MAP.get(entity_id)
p0, p1, year = _period_window(db, entity_id)
period_label = f"{p0}~{p1}" if p0 else None
if cfg is None or p0 is None:
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"lines": [],
"missing_reason": "该账套无可用凭证期" if cfg else "未配置科目映射",
},
"gross_margin": {"available": False, "value": None, "missing_reason": "无数据"},
"management": [],
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
}
base = (f"entity_id = :e AND period >= :p0 AND period <= :p1 AND {NOT_CARRY}")
params = {"e": entity_id, "p0": p0, "p1": p1}
wine = rc.wine_sql("summary")
rev_prefix = cfg["revenue"] + "%"
# ── 1. 收入:主营科目按业务线分列(全额售价,禁净额)──────────────────
revenue = {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"period": period_label,
"lines": [],
}
if cfg["lines"]:
# 表 1:酒类线(keywords 命中)
wine_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": rev_prefix})
# 表 2:非酒类线(同一科目、不命中 keywords)
other_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND NOT {wine}
""", {**params, "pfx": rev_prefix})
# e1 => [酒类, 其他];e2 => [自营(IT)=非酒类, 代销(酒类)=酒类]
values = {}
if entity_id == 1:
values = {"liquor": wine_net, "other": other_net}
elif entity_id == 2:
values = {"self_it": other_net, "agency_liquor": wine_net}
lines = []
for ln in cfg["lines"]:
raw = values.get(ln["key"])
lines.append({
"key": ln["key"],
"label": ln["label"],
"amount": _wan(raw),
"unit": "万元",
"data_status": "verified" if raw is not None else "missing",
"missing_reason": None if raw is not None else "该业务线本期无入账凭证",
})
total = _wan((wine_net or 0) + (other_net or 0)) if (wine_net is not None or other_net is not None) else None
revenue = {
**revenue,
"available": any(l["amount"] is not None for l in lines),
"lines": lines,
"total": total,
"total_label": "合计(各线相加,仅作复算校验)",
"merge_forbidden": True,
"note": "各业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
# ── 2. 毛利率:新式 (收入净额 − 折扣后净进价成本) / 收入净额 ────────────
gross_margin = {"available": False, "value": None, "missing_reason": "无数据"}
rev_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": rev_prefix})
cost_total = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["cost"] + "%"})
disc_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
if rev_total and float(rev_total) != 0 and cost_total is not None:
net_cost = float(cost_total) - float(disc_total or 0)
gm = (float(rev_total) - net_cost) / float(rev_total) * 100.0
gross_margin = {
"available": True,
"value": round(gm, 2),
"unit": "%",
"caliber": "net_purchase",
"caliber_label": "新式·折扣后净进价",
"formula": "(收入净额 − 折扣后净进价成本) / 收入净额 × 100",
"period": period_label,
# 四件套 0/4 ⇒ 历史期缺同票折扣发票,只可显示现值并带标识
"data_status": "estimated",
"data_tag": "测算/待票",
"note": "旧式(含 k 的 5001/571103 组合)已作废;历史缺票期不得冒充实账",
}
# ── 3. 管理指标(非会计科目,不进损益、不进摘要条主位)───────────────
management = []
if entity_id == 2:
# 与 A1 回执可复现口径一致:分母取**折让前贷方发生额**(639,944.00),
# 而不是 6603 净额(556,279.15,已抵扣借方)——两者差 28.24% 与 24.55% 之别。
disc = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
base_amt = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE '2202%' AND summary LIKE '%采购收货%'
AND {rc.supplier_sql('summary')}
""", params)
if disc is not None and base_amt and float(base_amt) != 0:
management.append({
"key": "vendor_discount_dependency",
"label": "厂商折扣依赖度",
"value": round(abs(float(disc)) / float(base_amt) * 100.0, 2),
"unit": "%",
"numerator": round(abs(float(disc)), 2),
"numerator_label": "酒类采购折扣额",
"denominator": round(float(base_amt), 2),
"denominator_label": "酒类采购额(含税)",
"period": period_label,
"tag": "管理指标·非会计科目",
"caliber_note": "折扣额 ÷ 酒类采购额(含税);改造单 v4 §3 文字写「÷销售额」,"
"但 28.24% 的复现分母是采购额,按可复现口径披露,分母已单列可复核",
})
elif entity_id == 1 and cfg.get("purchase_gift") and rev_total:
gift = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_gift"] + "%"})
if gift is not None and float(rev_total) != 0:
val = round(abs(float(gift)) / float(rev_total) * 100.0, 2)
management.append({
"key": "vendor_gift_dependency",
"label": "厂商赠酒依赖度",
"value": val,
"unit": "%",
"numerator": round(abs(float(gift)), 2),
"numerator_label": "采购获赠额",
"denominator": round(float(rev_total), 2),
"denominator_label": "营业收入(同期)",
"period": period_label,
"tag": "管理指标·非会计科目",
"abnormal": val > 100,
"caliber_note": "获赠额 ÷ 同期营业收入;>100% 属异常,须数据侧核查(不作损益行)",
})
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": revenue,
"gross_margin": gross_margin,
"management": management,
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
"caliber_source": {
"status": rc.STATUS,
"decided_by": rc.DECIDED_BY,
"reason": rc.REASON,
},
}
+56 -2
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from typing import Optional from typing import Optional
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from app.database import get_db from app.database import get_db
@@ -16,6 +17,15 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── C1 数据前置(改造单 v2 §3,硬约束)──────────────────────────────
# C_CUST_CONCENTRATION 的 69 个实测点含 2026-10 未来期与重复值;清理完成前:
# ① 相关目标节点显示「数据待核」、**不着色**(level=pending);
# ② 该 KPI 的实测值/达成率不得进入摘要条、节点详情、趋势图(96.3% 零出口)。
# 清理单据落地后删除本表即可恢复;不要在别处另写白名单。
DATA_HOLD_KPIS = {
"C_CUST_CONCENTRATION": "数据待核:C1 实测点含未来期与重复值,清理未完成",
}
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"], router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
dependencies=[Depends(require_role("ceo", "finance"))], dependencies=[Depends(require_role("ceo", "finance"))],
) )
@@ -74,6 +84,17 @@ def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all() maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
return {"data": [m_to_dict(m, db) for m in maps]} return {"data": [m_to_dict(m, db) for m in maps]}
@router.get("/strategy/revenue-caliber")
def strategy_revenue_caliber(entity_id: int = Depends(get_entity_id)):
"""战略地图摘要条 · 收入口径状态(口径已闭合,金额取 /maps/summary)。
2026-10-19 A1 终稿:两家均总额法;增值税销售货物 13%。
本端点只出口径与业务线维度(不含金额);金额一律由 /maps/summary 现算。
"""
from app.data import revenue_caliber
return revenue_caliber.snapshot(entity_id)
@router.post("") @router.post("")
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错) # 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
@@ -538,6 +559,8 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
"label": data.get("label", ""), "label": data.get("label", ""),
}) })
m.canvas_data["connections"] = conns m.canvas_data["connections"] = conns
# D2(2026-10-20):裸 JSON 列不做就地变更跟踪;显式标脏,与模型层 MutableDict 双保险
flag_modified(m, "canvas_data")
db.commit() db.commit()
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409 # updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
return {"connections": conns, "updated_at": _stamp(m.updated_at)} return {"connections": conns, "updated_at": _stamp(m.updated_at)}
@@ -560,6 +583,8 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_
raise HTTPException(404, "连线不存在") raise HTTPException(404, "连线不存在")
m.canvas_data["connections"] = new_conns m.canvas_data["connections"] = new_conns
# D2(2026-10-20):换新列表赋值同样不被裸 JSON 列跟踪 ⇒ 显式标脏(根因修复点)
flag_modified(m, "canvas_data")
db.commit() db.commit()
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}, return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
"updated_at": _stamp(m.updated_at)} "updated_at": _stamp(m.updated_at)}
@@ -1026,12 +1051,15 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
if level and kpi_def.kpi_level != level: if level and kpi_def.kpi_level != level:
continue continue
lv = latest_values.get(kpi_def.id, {}) lv = latest_values.get(kpi_def.id, {})
actual = lv.get("actual_value") # C1 数据前置:held KPI 的实测值一律不外泄(96.3% 零出口)
held = code in DATA_HOLD_KPIS
actual = None if held else lv.get("actual_value")
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5) # 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
target = kpi_def.target_monthly or kpi_def.target_value target = kpi_def.target_monthly or kpi_def.target_value
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%) # 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5) # 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
level = "gray" level = "gray"
ratio_val = None
if actual is not None and target: if actual is not None and target:
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "") name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
lower_better = any(k in name for k in lower_better = any(k in name for k in
@@ -1042,6 +1070,7 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
ratio = actual / target ratio = actual / target
if ratio < 0: if ratio < 0:
ratio = 0 # 负值目标(如亏损控制) 完成度不为负 ratio = 0 # 负值目标(如亏损控制) 完成度不为负
ratio_val = ratio
if ratio >= 0.9: if ratio >= 0.9:
level = "green" level = "green"
elif ratio >= 0.6: elif ratio >= 0.6:
@@ -1051,6 +1080,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
else: else:
level = "gray" level = "gray"
# C1 数据前置:held KPI 不着色、不透出达成率(level=pending 由前端灰虚线呈现)
if held:
level = "pending"
ratio_val = None
if level == "red": if level == "red":
worst_level = "red" worst_level = "red"
elif level == "yellow" and worst_level != "red": elif level == "yellow" and worst_level != "red":
@@ -1064,14 +1098,29 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"actual_value": actual, "actual_value": actual,
"unit": kpi_def.unit, "unit": kpi_def.unit,
"level": level, "level": level,
# 达成率(按指标方向归一,None=无目标/无实测,调用方须显式显示"无数据")
"ratio": round(ratio_val, 4) if ratio_val is not None else None,
}) })
kpi_ratios = [k["ratio"] for k in obj_kpis if k.get("ratio") is not None]
obj_ratio = round(sum(kpi_ratios) / len(kpi_ratios), 4) if kpi_ratios else None
# C1 数据前置:目标含 held KPI ⇒ 整节点「数据待核」,不计入任何达成率汇总
hold_reason = next((DATA_HOLD_KPIS[c] for c in obj.get("kpis", []) if c in DATA_HOLD_KPIS), None)
obj_item = { obj_item = {
"name": obj.get("name", ""), "name": obj.get("name", ""),
"icon": obj.get("icon", ""), "icon": obj.get("icon", ""),
"kpis": obj_kpis, "kpis": obj_kpis,
"level": worst_level, "level": "pending" if hold_reason else worst_level,
"has_data": len(obj_kpis) > 0, "has_data": len(obj_kpis) > 0,
# 目标达成率 = 有实测值 KPI 的方向归一达成率均值;None ⇒ 前端显示"无数据"
"ratio": None if hold_reason else obj_ratio,
"kpi_total": len(obj_kpis),
"kpi_with_data": 0 if hold_reason else len(kpi_ratios),
"has_actual": False if hold_reason else bool(kpi_ratios),
"data_hold": bool(hold_reason),
"hold_reason": hold_reason,
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计 # 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
"action_plan_summary": { "action_plan_summary": {
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]), "total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
@@ -1082,6 +1131,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
} }
objectives.append(obj_item) objectives.append(obj_item)
# C1 数据前置:数据待核目标单列,不进达标/关注/风险任何一档
if hold_reason:
total_hold += 1
continue
if worst_level == "green": if worst_level == "green":
total_ok += 1 total_ok += 1
elif worst_level == "yellow": elif worst_level == "yellow":
+1
View File
@@ -0,0 +1 @@
"""口径判据数据包(战略地图数据完备度等,只读常量)。"""
@@ -0,0 +1,73 @@
"""战略地图数据完备度 · 口径定义(唯一真源引用,勿改判据/分母)
口径 ID : CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0
定义件 : /srv/hermes/mailbox/project/pending-tasks/_reports/
caliber-strategy-map-completeness-20261018.md
裁定 : ASK-20261018-15 / 任总 2026-10-18T23:07:11「放行14和15」
本模块只放**判据数据**(23-KPI 挂接关系 + 来源指纹),不存任何汇总数:
覆盖率一律由 API 运行时对库内 `map_objectives` / `kpi_definitions` 现算。
口径变更 = 重新立 ASK 并改本文件 + 定义件,禁止就地改分母。
"""
CALIBER_ID = "CMA-CALIBER-MAP-COMPLETENESS-20261018"
CALIBER_VERSION = "1.0"
# 主口径:仅已发布地图 65(三年战略规划 2027-2029)
PRIMARY_MAP_ID = 65
PRIMARY_CALIBER = "map65_23kpi_cover"
PRIMARY_LABEL = "地图65·已发布·判据=被23KPI集挂接"
FOOTNOTE_CALIBER = "map_all_kpi_attached"
FOOTNOTE_LABEL = "全量4图·含归档图·判据=有KPI挂接"
KPI_CALIBER = "active_kpi_rows"
KPI_LABEL = "KPI 行(active·按行)"
# 来源指纹(定义件 §5,任何人可复现)
# 注:键名为上游件原名(其行数标记属上游历史口径),与本单交付分母无关,不得据此推算分母。
SOURCE_FILES = {
"map-objectives-dump.csv":
"3896963ee7aff0492073f9b61491a10b10f3b131484e1f1006655d2c2f9bda78",
"a2-linkage-23kpi.csv":
"4494313269938663a197fabecaee644a4f4c66c84a02b725527c07e917f63971",
"active-kpi-118.csv":
"e37dcd699a06a18d5de9aa5b543b7a48b6d7d12c3e4c440f21186ea870cda1c1",
}
# 被 23-KPI 集挂接的战略目标(objective_id -> (map_id, name))
# 来源 a2-linkage-23kpi.csv「挂接类型非空」逐行,26 条(全量 4 图)。
LINKED_OBJECTIVES = {
1020: (53, "营收增长"),
1022: (53, "客户拓展"),
1024: (53, "运营效率"),
1025: (53, "数字化能力"),
1021: (53, "成本与现金流"),
1023: (53, "客户亲密"),
20888: (57, "营收目标"),
20889: (57, "建立渠道分级体系"),
21013: (61, "营收目标"),
21014: (61, "保障现金流安全"),
21016: (61, "产品领先"),
22556: (65, "营业收入"),
22562: (65, "高贡献线收入占比"),
22566: (65, "组合决策机制(负贡献四线退出转投)"),
22571: (65, "贡献率思维全员培训"),
22557: (65, "营业利润"),
22563: (65, "重点客户回款率"),
22567: (65, "月度经营闭环"),
22572: (65, "渠道经理「选品+算账」能力认证"),
22558: (65, "边际贡献率 CM"),
22568: (65, "库存去化"),
22573: (65, "CMA 月度经营驾驶舱"),
22559: (65, "现金周转期 CCC"),
22565: (65, "客户集中度"),
22574: (65, "贡献率/CCC/净现金入考核与激励"),
22570: (65, "数据地基(清脏+改瓶+智享通直连)"),
}
# 分层标签(UI 用,与前端 LAYER_CONFIG 一致)
LAYER_LABELS = {
"finance": "财务",
"customer": "客户",
"process": "内部流程",
"learning": "学习成长",
}
+109
View File
@@ -0,0 +1,109 @@
"""战略地图 · 收入 / 毛利 / 管理指标口径(单一事实源,勿在别处写死)
口径来源(引用勿改):
/srv/hermes/mailbox/finance/pending-tasks/_reports/
project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md
改造单 project-20261018-cma-strategy-map-ux-revamp-fullstack 增量 v4
终稿结论(2026-10-19):
- 陕西博海、陕西酣客 **均总额法(主要责任人)**;
- 增值税 = 销售货物 13%(账内 11 张凭证实测,6% 代理服务零证据);
- 上游披露的「累计口径」金额(非年度数)= 冲减采购成本/存货成本(唯一落位,**无「补贴收入/其他收益」行**),
本模块不产生该数、也不允许任何调用点拼出该串(千分位与裸整数两种写法均在禁列);
- F2 毛利率新式 = (收入净额 − 折扣后净进价成本) / 收入净额 × 100;
旧式(含 `k` 的 5001/571103 组合)作废,**不得在本文件或调用点复现**;
- 历史期缺同票折扣发票(四件套 0/4)⇒ 金额只作现值展示并带「测算/待票」标识。
本模块只放**口径与科目映射**,不存金额;金额一律由 API 对库内 voucher_details 现算
(filter: summary NOT LIKE '%结转%',否则损益双向抵销归零)。
"""
# ── 收入口径状态(v4 起为已闭合)──────────────────────────────────────────────
STATUS = "closed"
CALIBER = "total" # total / net / mixed
CALIBER_LABEL = "总额法(会计)· 增值税13%"
DECIDED_BY = "A1 终稿(project-20261018-strategy-map-data-a1-addendum-finance-v2-return.md)"
REASON = "两家均主要责任人 ⇒ 总额法;增值税销售货物 13% 实测,无税会差异"
# ── 按实体的科目映射(各账套科目体系不同:酣客 5001/5401,博海 6001/6401)──────
SUBJECT_MAP = {
1: {
"revenue": "5001", # 主营业务收入
"cost": "5401", # 主营业务成本
"purchase_discount": "530111", # 商品折扣(采购)
"purchase_gift": "530103", # 采购获赠(捐赠收益)
"lines": [
{"key": "liquor", "label": "酒类"},
{"key": "other", "label": "其他"},
],
},
2: {
"revenue": "6001", # 主营业务收入
"cost": "6401", # 主营业务成本
"purchase_discount": "6603", # 财务费用-现金折扣(贷方=采购折扣)
"purchase_gift": None,
"lines": [
{"key": "self_it", "label": "自营(IT)"},
{"key": "agency_liquor", "label": "代销(酒类)"},
],
},
}
# 酒类判定(财务 A1 件同一口径):摘要含以下任一关键词 ⇒ 酒类业务线
WINE_KEYWORDS = ("酣客", "酒窖", "酣亲", "秦莉", "牛彦平", "横山")
# 采购来源(博海酒类采购:陕西酣客 / 酣客酒业)
LIQUOR_SUPPLIERS = ("酣客", "酒业")
def wine_sql(column: str = "summary") -> str:
"""SQL 片段:酒类业务线判定(与财务回执同口径,勿改)。"""
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in WINE_KEYWORDS) + ")"
def supplier_sql(column: str = "summary") -> str:
return "(" + " OR ".join(f"{column} LIKE '%{k}%'" for k in LIQUOR_SUPPLIERS) + ")"
# ── 口径切换期标识位(v4 §4:不硬编码日期;生效日以任总拍板 ASK 为准)──────────
CALIBER_SWITCH = {
"has_switch": True,
"label": "口径变更",
"effective_date": None, # 未拍板前恒为 None —— 禁止写死日期
"comparability": "口径变更·不可比",
"note": "同比/达成率/预算执行率在切换期内不可比;生效日以任总拍板(ASK)为准",
}
# ── 缺件披露(v4 §5:界面不得呈现为「已齐」)─────────────────────────────────
MISSING_DOCS = [
"经销/代理协议",
"厂家价格政策",
"逐笔折扣发票+合同审批单",
"上游退货条款",
"发票台账(invoice_check 空)",
]
def business_lines(entity_id: int) -> list:
cfg = SUBJECT_MAP.get(entity_id)
return list(cfg["lines"]) if cfg else []
def snapshot(entity_id: int) -> dict:
"""对外口径快照(无金额;金额见 /api/cma/maps/summary)。"""
return {
"entity_id": entity_id,
"status": STATUS,
"caliber": CALIBER,
"caliber_label": CALIBER_LABEL,
"decided_by": DECIDED_BY,
"reason": REASON,
"allow_single_total": False, # 分业务线分列;不合并成单一「营业收入」
"businesses": [
{"key": b["key"], "label": b["label"], "unit": "万元"}
for b in business_lines(entity_id)
],
"switch": CALIBER_SWITCH,
"missing_docs": MISSING_DOCS,
"note": "分业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
+6 -1
View File
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from dotenv import load_dotenv from dotenv import load_dotenv
from app.database import init_db, get_db from app.database import init_db, get_db
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_dimensions from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, bot_suggestions, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify, cashflow_structure, cash_vouchers_json, ext_input_ledgers, treasury, doc_center, map_completeness, map_summary, map_dimensions
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, require_auth_401 from app.auth_middleware import require_auth, require_auth_401
@@ -41,6 +41,11 @@ app.include_router(kpis.router)
app.include_router(kpi_governance.router) app.include_router(kpi_governance.router)
app.include_router(templates.router) app.include_router(templates.router)
app.include_router(maps.router) app.include_router(maps.router)
# 顺序铁律(2026-10-20 D1 修复):同前缀族的路由必须把**字面路径段**路由器 include 在
# 任何 `/{map_id}/<literal>` 参数化路由(objectives.router 的 GET /{map_id}/objectives)之前,
# 否则 /api/cma/maps/completeness/objectives 会被判 map_id="completeness" → 恒 422。
app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(data.router) app.include_router(data.router)
app.include_router(alerts.router) app.include_router(alerts.router)
+2 -1
View File
@@ -1,5 +1,6 @@
"""管理会计OS 数据模型""" """管理会计OS 数据模型"""
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, text, UniqueConstraint, Index, Numeric, Date from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, text, UniqueConstraint, Index, Numeric, Date
from sqlalchemy.ext.mutable import MutableDict
from app.database import Base from app.database import Base
from app.models.budget_plan import BudgetPlan from app.models.budget_plan import BudgetPlan
@@ -59,7 +60,7 @@ class StrategicMap(Base):
version = Column(String(20), default="v1.0", comment="版本号") version = Column(String(20), default="v1.0", comment="版本号")
status = Column(String(20), default="draft", comment="draft/published") status = Column(String(20), default="draft", comment="draft/published")
dimensions = Column(JSON, nullable=True, comment="四维度和目标列表") dimensions = Column(JSON, nullable=True, comment="四维度和目标列表")
canvas_data = Column(JSON, nullable=True, comment="画布连线数据") canvas_data = Column(MutableDict.as_mutable(JSON), nullable=True, comment="画布连线数据")
created_by = Column(Integer, nullable=True) created_by = Column(Integer, nullable=True)
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())
@@ -0,0 +1,128 @@
"""D2(2026-10-20 fullstack-20261020-strategy-map-3defects-fix)
缺陷:`DELETE /api/cma/maps/{id}/connections` 返回 200 但库里没变(POST 同样不可靠)。
根因:`StrategicMap.canvas_data` 是裸 `Column(JSON)`,无变更跟踪;接口用
「`m.canvas_data["connections"] = new_conns`」就地改,flush 时该列未被判脏 ⇒ 不写库。
判据铁律(任务 §1.2):**不能**用「响应体里 connections 变短了」当落库证据 ——
必须新开 session 用原生 SQL 复读 `canvas_data`。
"""
import json
from sqlalchemy import text
from app.models import StrategicMap
def _raw_canvas_data(map_id: int) -> dict:
"""**新 session + 原生 SQL** 复读(不复用请求内 session)。"""
from tests.conftest import TEST_SESSION_LOCAL
session = TEST_SESSION_LOCAL()
try:
row = session.execute(
text("SELECT canvas_data FROM strategic_maps WHERE id = :mid"), {"mid": map_id}
).first()
assert row is not None, f"strategic_maps.id={map_id} 不存在"
raw = row[0]
finally:
session.close()
if isinstance(raw, (str, bytes)):
return json.loads(raw)
return raw or {}
def _pairs(canvas: dict):
return [(c.get("from"), c.get("to")) for c in (canvas.get("connections") or [])]
def test_add_connection_persists_across_sessions(client, db):
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
create_test_user(db)
m = create_test_map(db, entity_id=1) # canvas_data={"connections": []}
db.commit()
token = get_token_for_user(client)
h = auth_header(token)
resp = client.post(
f"/api/cma/maps/{m.id}/connections",
json={"from": "learning-0", "to": "process-0"},
headers=h,
)
assert resp.status_code == 200, resp.text
assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(m.id)), (
"POST /connections 返回 200 但新 session 原生 SQL 读不到该连线(未落库)"
)
def test_delete_connection_persists_across_sessions(client, db):
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
create_test_user(db)
m = create_test_map(
db,
entity_id=1,
canvas_data={"connections": [
{"from": "learning-0", "to": "process-0", "style": "solid", "effect": "positive", "label": ""},
{"from": "process-0", "to": "customer-0", "style": "solid", "effect": "positive", "label": ""},
]},
)
db.commit()
token = get_token_for_user(client)
h = auth_header(token)
resp = client.request(
"DELETE",
f"/api/cma/maps/{m.id}/connections",
json={"from": "learning-0", "to": "process-0"},
headers=h,
)
assert resp.status_code == 200, resp.text
persisted = _pairs(_raw_canvas_data(m.id))
assert ("learning-0", "process-0") not in persisted, (
f"DELETE 返回 200 但新 session 原生 SQL 仍读到已删连线:{persisted}"
)
assert ("process-0", "customer-0") in persisted, "无关连线被误删"
def test_delete_missing_connection_still_404(client, db):
"""负测:删除不存在的 from/to 仍 404(不得因标脏改动变成 200)。"""
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
create_test_user(db)
m = create_test_map(db, entity_id=1)
db.commit()
token = get_token_for_user(client)
h = auth_header(token)
resp = client.request(
"DELETE",
f"/api/cma/maps/{m.id}/connections",
json={"from": "learning-9", "to": "process-9"},
headers=h,
)
assert resp.status_code == 404, resp.text
def test_canvas_data_mutation_tracked_by_mutable_dict(db):
"""模型层双保险:MutableDict 包装后,**不经 flag_modified** 的 dict 赋值也应落库。
说明:`MutableDict.as_mutable(JSON)` 返回的仍是 JSON 类型实例(跟踪靠 mapper 级
attribute 事件 + coerce),因此这里用**行为断言**而不是断言列类型类名。
"""
m = StrategicMap(title="MutableDict 行为验证", entity_id=1, canvas_data={"connections": []})
db.add(m)
db.commit()
mid = m.id
db.expire_all()
loaded = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
# 就地改 dict(等价于接口里的 `m.canvas_data["connections"] = new_conns`)
loaded.canvas_data["connections"] = [{"from": "learning-0", "to": "process-0"}]
db.commit() # 不调用 flag_modified
assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(mid)), (
"canvas_data 未被 MutableDict 跟踪:dict 赋值后 commit 未落库"
)
@@ -0,0 +1,108 @@
"""D1(2026-10-20 fullstack-20261020-strategy-map-3defects-fix)
缺陷:`GET /api/cma/maps/completeness/objectives` 恒 422。
根因(项目Bot 复核修正):`main.py` 先 include 的 `objectives.router`
(prefix `/api/cma/maps`,`GET /{map_id}/objectives`)把 `map_id="completeness"`
当 int 解析 → 422;真抢占者不是 `maps.router`。
修复:`map_completeness.router` / `map_summary.router` 前移到 `objectives.router` 之前。
本文件钉住三件事:
1) 该 path 的首个 FULL match 落在 `app.api.map_completeness`(比 200/401 更硬,不依赖 DB);
2) `/completeness/summary` 不回归;
3) `/maps/{map_id}/objectives` 仍命中 `app.api.objectives`;
4) 同前缀族全量巡检:任何字面路径路由都不得被先注册的参数化路由吃掉。
"""
from pathlib import Path
from fastapi.routing import APIRoute
from starlette.routing import Match
from app.main import app
COMPLETENESS_OBJECTIVES = "/api/cma/maps/completeness/objectives"
def _first_full_match(path: str, method: str = "GET"):
"""按注册顺序返回首个 FULL match 的 route(= Starlette 实际分发结果)。"""
scope = {"type": "http", "method": method, "path": path, "headers": []}
for route in app.routes:
if not hasattr(route, "matches"):
continue
match, _child = route.matches(scope)
if match == Match.FULL:
return route
return None
def _ep(route) -> str:
endpoint = getattr(route, "endpoint", None)
return f"{getattr(endpoint, '__module__', '?')}.{getattr(endpoint, '__name__', '?')}"
def test_app_imported_from_this_worktree():
"""防呆:pytest.ini 的 pythonpath 指向部署树,必须断言用的是当前工作区代码。"""
from app import main as app_main
repo_backend = Path(__file__).resolve().parents[1]
assert Path(app_main.__file__).resolve().is_relative_to(repo_backend), (
f"导入了非本工作区的 app.main:{app_main.__file__}(期望在 {repo_backend} 下)"
)
def test_completeness_objectives_route_resolves_to_map_completeness():
route = _first_full_match(COMPLETENESS_OBJECTIVES)
assert route is not None, f"没有路由匹配 {COMPLETENESS_OBJECTIVES}"
assert _ep(route) == "app.api.map_completeness.completeness_objectives", (
f"{COMPLETENESS_OBJECTIVES} 被抢先解析为 {_ep(route)}"
)
assert getattr(route, "path", None) == COMPLETENESS_OBJECTIVES
def test_completeness_summary_not_regressed():
route = _first_full_match("/api/cma/maps/completeness/summary")
assert route is not None
assert _ep(route) == "app.api.map_completeness.completeness_summary", _ep(route)
def test_map_summary_not_shadowed():
route = _first_full_match("/api/cma/maps/summary")
assert route is not None
assert _ep(route) == "app.api.map_summary.map_summary", _ep(route)
def test_map_objectives_endpoint_not_regressed():
route = _first_full_match("/api/cma/maps/65/objectives")
assert route is not None
assert _ep(route) == "app.api.objectives.list_objectives", _ep(route)
def test_no_literal_path_shadowed_in_maps_prefix_family():
"""同前缀族全量巡检:/api/cma/maps* 下所有**字面**路径的首个匹配必须是自己。"""
family = [
r for r in app.routes
if isinstance(r, APIRoute)
and r.path.startswith("/api/cma/maps")
and "{" not in r.path
]
assert family, "未发现 /api/cma/maps* 字面路径路由,巡检面为空(异常)"
problems = []
for r in family:
for method in sorted((r.methods or set()) - {"HEAD", "OPTIONS"}):
first = _first_full_match(r.path, method)
if first is not r:
problems.append(f"{method} {r.path} -> {_ep(first)}(应为 {_ep(r)})")
assert not problems, "同前缀族路由被抢占:\n" + "\n".join(problems)
def test_completeness_objectives_http_not_422(client, db):
"""端到端:带凭据请求不得出现 loc=["path","map_id"] / input="completeness" 的 422。"""
from tests.conftest import auth_header, create_test_user, get_token_for_user
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(COMPLETENESS_OBJECTIVES, headers=auth_header(token))
assert resp.status_code != 422, resp.text
assert resp.status_code == 200, resp.text
body = resp.json()
assert "objectives" in body or isinstance(body, dict)
+5 -1
View File
@@ -15,7 +15,6 @@ declare module 'vue' {
BotSourceTag: typeof import('./src/components/BotSourceTag.vue')['default'] BotSourceTag: typeof import('./src/components/BotSourceTag.vue')['default']
BudgetCollectPanel: typeof import('./src/components/BudgetCollectPanel.vue')['default'] BudgetCollectPanel: typeof import('./src/components/BudgetCollectPanel.vue')['default']
BulletChart: typeof import('./src/components/charts/BulletChart.vue')['default'] BulletChart: typeof import('./src/components/charts/BulletChart.vue')['default']
CaliberBadge: typeof import('./src/components/CaliberBadge.vue')['default']
CashClassifyPanel: typeof import('./src/components/CashClassifyPanel.vue')['default'] CashClassifyPanel: typeof import('./src/components/CashClassifyPanel.vue')['default']
ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default'] ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.vue')['default']
ConnectionManagerDialog: typeof import('./src/components/map-canvas/ConnectionManagerDialog.vue')['default'] ConnectionManagerDialog: typeof import('./src/components/map-canvas/ConnectionManagerDialog.vue')['default']
@@ -81,8 +80,13 @@ declare module 'vue' {
KPIListView: typeof import('./src/components/KPIListView.vue')['default'] KPIListView: typeof import('./src/components/KPIListView.vue')['default']
MapCanvasDialogs: typeof import('./src/components/map-canvas/MapCanvasDialogs.vue')['default'] MapCanvasDialogs: typeof import('./src/components/map-canvas/MapCanvasDialogs.vue')['default']
MapCanvasToolbar: typeof import('./src/components/map-canvas/MapCanvasToolbar.vue')['default'] MapCanvasToolbar: typeof import('./src/components/map-canvas/MapCanvasToolbar.vue')['default']
MapCompleteness: typeof import('./src/components/strategy-map/MapCompleteness.vue')['default']
MapDimensionFilter: typeof import('./src/components/map-canvas/MapDimensionFilter.vue')['default'] MapDimensionFilter: typeof import('./src/components/map-canvas/MapDimensionFilter.vue')['default']
MapEditBar: typeof import('./src/components/strategy-map/MapEditBar.vue')['default']
MapNode: typeof import('./src/components/strategy-map/MapNode.vue')['default'] MapNode: typeof import('./src/components/strategy-map/MapNode.vue')['default']
MapNodeDetail: typeof import('./src/components/strategy-map/MapNodeDetail.vue')['default']
MapSummaryBar: typeof import('./src/components/strategy-map/MapSummaryBar.vue')['default']
MapTargetTree: typeof import('./src/components/strategy-map/MapTargetTree.vue')['default']
MyDialog: typeof import('./src/components/MyDialog.vue')['default'] MyDialog: typeof import('./src/components/MyDialog.vue')['default']
NodeEditDialog: typeof import('./src/components/strategy-map/NodeEditDialog.vue')['default'] NodeEditDialog: typeof import('./src/components/strategy-map/NodeEditDialog.vue')['default']
OkrDecomposition: typeof import('./src/components/okr/OkrDecomposition.vue')['default'] OkrDecomposition: typeof import('./src/components/okr/OkrDecomposition.vue')['default']
+3
View File
@@ -75,6 +75,9 @@ export const mapApi = {
versionRollback: (mapId: number, vid: number) => api.post(`/maps/${mapId}/versions/${vid}/rollback`), versionRollback: (mapId: number, vid: number) => api.post(`/maps/${mapId}/versions/${vid}/rollback`),
versionUpdateComment: (mapId: number, vid: number, comment: string) => api.put(`/maps/${mapId}/versions/${vid}`, { comment }), versionUpdateComment: (mapId: number, vid: number, comment: string) => api.put(`/maps/${mapId}/versions/${vid}`, { comment }),
versionSnapshot: (mapId: number, comment?: string) => api.post(`/maps/${mapId}/versions/snapshot`, comment ? { comment } : {}), versionSnapshot: (mapId: number, comment?: string) => api.post(`/maps/${mapId}/versions/snapshot`, comment ? { comment } : {}),
// 数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0 · 2026-10-18)
completenessSummary: () => api.get('/maps/completeness/summary'),
completenessObjectives: (params?: any) => api.get('/maps/completeness/objectives', { params }),
} }
// 复盘记录持久化(大PDCA Act闭环 2026-08-27) // 复盘记录持久化(大PDCA Act闭环 2026-08-27)
@@ -0,0 +1,210 @@
<template>
<section class="mc-root" aria-label="战略地图数据完备度">
<div v-if="loading" class="mc-loading">加载中…</div>
<template v-else>
<!-- 主数:仅地图 65(已发布)→ 全页唯一主数位 -->
<div class="mc-main">
<div class="mc-main-head">
<span class="mc-main-title">数据完备度 · 目标覆盖</span>
<span class="mc-tag" data-caliber="map65_23kpi_cover">{{ primary.label }}</span>
</div>
<div v-if="primary.available" class="mc-main-body">
<div class="mc-main-value mono">
{{ primary.covered }}<span class="mc-slash">/</span>{{ primary.total }}
</div>
<div class="mc-main-pct mono">{{ primary.pct }}<span class="mc-unit">%</span></div>
</div>
<div v-else class="mc-main-body">
<div class="mc-main-value mono muted">—</div>
<div class="mc-main-pct muted">无数据</div>
</div>
<div class="mc-note">
分母 = {{ primary.available ? primary.map_title : '地图 65' }} 的
{{ primary.available ? primary.total : '—' }}个战略目标;
分子 = 被 23-KPI 集挂接的目标(挂接类型非空)
</div>
</div>
<!-- 附注:全量 4 图(含归档图),判据不同,字号/位置低于主数,且同句带标签 -->
<div class="mc-foot" data-caliber="map_all_kpi_attached">
<span class="mc-foot-label">附注</span>
<span class="mc-foot-text">
{{ footnote.label }}:<b class="mono">{{ footnote.display }}</b>
<template v-if="footnote.available"> = <b class="mono">{{ footnote.pct }}%</b></template>
<template v-else> · 无数据</template>
</span>
</div>
<!-- KPI 级:与目标级分列,不相除、不换算 -->
<div class="mc-kpi" data-caliber="active_kpi_rows">
<div class="mc-kpi-head">
<span class="mc-kpi-title">KPI 行(active · 按行)</span>
<span class="mc-kpi-total mono">{{ kpi.total }}</span>
</div>
<div class="mc-kpi-entities">
<span v-for="e in kpi.by_entity" :key="e.entity_id" class="mc-kpi-chip">
{{ entityLabel(e.entity_id) }} <b class="mono">{{ e.count }}</b>
</span>
<span class="mc-kpi-note mono">去重编码 {{ kpi.distinct_codes }}(另册,不与行数混用)</span>
</div>
<div class="mc-note">目标级与 KPI 级是两个层级的分母,本页分列展示,不做相除或换算</div>
</div>
<!-- 清单:目标级逐行(entity × 地图 × 层 × 目标) -->
<div class="mc-list-head">
<span>数据完备度清单 · 目标级</span>
<span class="mc-list-count mono">{{ rows.length }} 行</span>
</div>
<div v-if="rows.length === 0" class="mc-empty">
该实体暂无可统计的战略目标 · 去创建
</div>
<div v-else class="mc-table-wrap">
<table class="mc-table">
<thead>
<tr>
<th>地图</th>
<th>层级</th>
<th>战略目标</th>
<th>23-KPI 挂接</th>
<th>关联 KPI</th>
<th>完备状态</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.objective_id">
<td class="mono">{{ r.map_id }}</td>
<td>{{ r.layer_label }}</td>
<td :title="r.name">{{ r.name }}</td>
<td>
<span class="mc-pill" :class="r.covered_23kpi ? 'ok' : 'off'">
{{ r.covered_23kpi ? '已挂接' : '未挂接' }}
</span>
</td>
<td class="mc-kpis">{{ r.kpis.length ? r.kpis.join(' / ') : '无' }}</td>
<td>
<span class="mc-pill" :class="stateClass(r)">{{ stateText(r) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { mapApi } from '../../api/index'
const loading = ref(true)
const error = ref<string | null>(null)
const summary = ref<any>(null)
const rows = ref<any[]>([])
const primary = computed<any>(() => summary.value?.objective_level?.primary ?? { available: false, label: '地图65·已发布·判据=被23KPI集挂接' })
const footnote = computed<any>(() => summary.value?.objective_level?.footnote ?? { available: false, label: '全量4图·含归档图·判据=有KPI挂接', display: '—' })
const kpi = computed<any>(() => summary.value?.kpi_level ?? { total: null, by_entity: [], distinct_codes: null })
const ENTITY_LABELS: Record<number, string> = { 1: '酣客', 2: '博海' }
function entityLabel(id: number) {
return ENTITY_LABELS[id] ? `${ENTITY_LABELS[id]}(${id})` : `实体${id}`
}
function stateText(r: any) {
if (r.covered_23kpi) return '23-KPI 覆盖'
if (r.has_kpi) return '非23KPI 覆盖'
return '缺口(无 KPI)'
}
function stateClass(r: any) {
if (r.covered_23kpi) return 'ok'
if (r.has_kpi) return 'warn'
return 'bad'
}
async function load() {
loading.value = true
error.value = null
try {
const [s, o]: any = await Promise.all([
mapApi.completenessSummary(),
mapApi.completenessObjectives(),
])
summary.value = s
rows.value = Array.isArray(o?.rows) ? o.rows : []
} catch (e: any) {
error.value = e?.message || '数据完备度加载失败'
summary.value = null
rows.value = []
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.mc-root { display: flex; flex-direction: column; gap: 10px; }
.mc-loading, .mc-empty {
padding: 24px; text-align: center; color: var(--bh-text-muted, #94A3B8); font-size: 13px;
border: 1px dashed var(--bh-border, #E5E7EB); border-radius: 8px;
}
.mc-main {
border: 1px solid var(--bh-border, #E5E7EB); border-radius: 8px; padding: 12px 14px;
background: var(--bh-bg-card, #fff);
}
.mc-main-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.mc-main-title { font-size: 13px; color: var(--bh-text-secondary, #64748B); }
.mc-tag {
font-size: 11px; padding: 1px 6px; border-radius: 4px;
color: #635BFF; background: rgba(99, 91, 255, .08); border: 1px solid rgba(99, 91, 255, .25);
}
.mc-main-body { display: flex; align-items: baseline; gap: 10px; margin-top: 6px; }
.mc-main-value { font-size: 30px; font-weight: 700; line-height: 1.1; color: var(--bh-text-primary, #0F172A); }
.mc-slash { color: var(--bh-text-muted, #94A3B8); font-weight: 500; margin: 0 2px; }
.mc-main-pct { font-size: 18px; font-weight: 600; color: #0E9F6E; }
.mc-unit { font-size: 11px; margin-left: 1px; }
.mc-note { margin-top: 6px; font-size: 11px; color: var(--bh-text-muted, #94A3B8); line-height: 1.5; }
.muted { color: var(--bh-text-muted, #94A3B8); }
.mc-foot {
display: flex; align-items: baseline; gap: 8px; padding: 8px 12px;
border-left: 3px solid var(--bh-border, #E5E7EB); background: #FAFAFB; border-radius: 4px;
font-size: 12px; color: var(--bh-text-secondary, #64748B);
}
.mc-foot-label { font-size: 11px; color: var(--bh-text-muted, #94A3B8); }
.mc-kpi {
border: 1px solid var(--bh-border, #E5E7EB); border-radius: 8px; padding: 10px 12px;
}
.mc-kpi-head { display: flex; align-items: baseline; justify-content: space-between; }
.mc-kpi-title { font-size: 13px; color: var(--bh-text-secondary, #64748B); }
.mc-kpi-total { font-size: 20px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
.mc-kpi-entities { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; align-items: center; }
.mc-kpi-chip {
font-size: 12px; padding: 2px 8px; border-radius: 4px; background: #F3F4F6; color: var(--bh-text-primary, #0F172A);
}
.mc-kpi-note { font-size: 11px; color: var(--bh-text-muted, #94A3B8); }
.mc-list-head {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 13px; color: var(--bh-text-secondary, #64748B); margin-top: 2px;
}
.mc-list-count { font-size: 12px; color: var(--bh-text-muted, #94A3B8); }
.mc-table-wrap { max-height: 340px; overflow: auto; border: 1px solid var(--bh-border, #E5E7EB); border-radius: 8px; }
.mc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.mc-table th {
position: sticky; top: 0; background: #F8FAFC; text-align: left; font-weight: 600;
color: var(--bh-text-secondary, #64748B); padding: 7px 10px; border-bottom: 1px solid var(--bh-border, #E5E7EB);
white-space: nowrap;
}
.mc-table td { padding: 6px 10px; border-bottom: 1px solid #F1F5F9; color: var(--bh-text-primary, #0F172A); }
.mc-table tbody tr:last-child td { border-bottom: none; }
.mc-table td:nth-child(3) { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-kpis { color: var(--bh-text-secondary, #64748B); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mc-pill { font-size: 11px; padding: 1px 6px; border-radius: 10px; white-space: nowrap; }
.mc-pill.ok { color: #0E9F6E; background: rgba(14, 159, 110, .08); }
.mc-pill.warn { color: #B45309; background: rgba(245, 158, 11, .12); }
.mc-pill.bad { color: #B91C1C; background: rgba(239, 68, 68, .10); }
.mc-pill.off { color: var(--bh-text-muted, #94A3B8); background: #F3F4F6; }
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
</style>
@@ -0,0 +1,170 @@
<template>
<div class="map-editbar">
<div class="eb-left">
<el-input
:model-value="search"
class="eb-search"
size="small"
clearable
placeholder="搜索目标 / 负责人 / KPI"
@update:model-value="(v: string) => $emit('update:search', v)"
>
<template #prefix>
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
</svg>
</template>
</el-input>
<div class="eb-chips" role="group" aria-label="状态筛选">
<button
v-for="opt in filterOptions"
:key="opt.value"
type="button"
class="eb-chip"
:class="{ on: filter === opt.value }"
@click="$emit('update:filter', opt.value)"
>{{ opt.label }}</button>
</div>
<div class="eb-chips eb-layers" role="group" aria-label="层筛选">
<button
v-for="cfg in layerConfigs"
:key="cfg.key"
type="button"
class="eb-chip layer-chip"
:class="{ off: !visibleLayers[cfg.key] }"
:title="visibleLayers[cfg.key] ? '隐藏该层' : '显示该层'"
@click="$emit('toggle-layer', cfg.key)"
>
<span class="layer-dot" :style="{ background: cfg.color }"></span>
{{ cfg.short }}
</button>
</div>
</div>
<div class="eb-right">
<el-dropdown trigger="click" @command="onAdd">
<el-button size="small" type="primary" plain>
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
加目标
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="cfg in layerConfigs" :key="cfg.key" :command="cfg.key">
<span class="dd-dot" :style="{ background: cfg.color }"></span>{{ cfg.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" @click="$emit('connect')">
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1" />
<path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />
</svg>
连因果
</el-button>
<el-button size="small" :disabled="!canUndo" @click="$emit('undo')" title="撤销上一步画布编辑">
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M9 14L4 9l5-5" /><path d="M4 9h10a6 6 0 0 1 0 12h-3" />
</svg>
撤销
</el-button>
<el-button size="small" @click="$emit('completeness')" title="数据完备度与清单(口径:地图65·23KPI判据)">
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M4 19V5" /><path d="M4 19h16" /><rect x="7" y="11" width="3" height="6" rx=".5" />
<rect x="12" y="7" width="3" height="10" rx=".5" /><rect x="17" y="13" width="3" height="4" rx=".5" />
</svg>
数据完备度
</el-button>
<el-button size="small" @click="$emit('fit')" title="缩放到适合窗口">
<svg class="eb-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
</svg>
回全景
</el-button>
<el-button size="small" text @click="$emit('toggle-tree')">
{{ treeOpen ? '收起目标树' : '展开目标树' }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
search: string
filter: 'all' | 'risk' | 'nodata'
visibleLayers: Record<string, boolean>
layerConfigs: { key: string; label: string; short: string; color: string }[]
canUndo: boolean
treeOpen: boolean
}>()
const emit = defineEmits<{
(e: 'update:search', v: string): void
(e: 'update:filter', v: 'all' | 'risk' | 'nodata'): void
(e: 'toggle-layer', key: string): void
(e: 'add-objective', key: string): void
(e: 'connect'): void
(e: 'undo'): void
(e: 'fit'): void
(e: 'toggle-tree'): void
(e: 'completeness'): void
}>()
const filterOptions: { value: 'all' | 'risk' | 'nodata'; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'risk', label: '只看风险' },
{ value: 'nodata', label: '只看无数据' },
]
function onAdd(key: string) {
emit('add-objective', key)
}
</script>
<style scoped>
.map-editbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
flex-shrink: 0;
padding: 8px 10px;
margin-bottom: 10px;
background: var(--bh-bg-card, #fff);
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px;
}
.eb-left, .eb-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.eb-search { width: 220px; }
.eb-ico { width: 14px; height: 14px; vertical-align: -2px; }
.eb-chips { display: inline-flex; align-items: center; gap: 4px; padding: 2px; background: #F6F7F9; border: 1px solid #E5E7EB; border-radius: 6px; }
.eb-chip {
border: 0;
background: transparent;
color: #475569;
font-size: 12px;
line-height: 20px;
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background .15s, color .15s;
}
.eb-chip:hover { color: #635BFF; }
.eb-chip.on { background: #635BFF; color: #fff; }
.eb-chip.off { color: #94A3B8; text-decoration: line-through; }
.layer-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.dd-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
</style>
@@ -1,6 +1,7 @@
<template> <template>
<div <div
class="map-node" class="map-node"
:data-node-key="nodeKey"
:class="{ :class="{
'linking-source': isLinkingSource, 'linking-source': isLinkingSource,
'linking-target': isLinkingTarget, 'linking-target': isLinkingTarget,
@@ -118,7 +119,7 @@
></div> ></div>
</div> </div>
<span class="node-progress-text" :style="{ color: progressColor(progressData.level) }"> <span class="node-progress-text" :style="{ color: progressColor(progressData.level) }">
{{ progressData.ratio > 0 ? progressData.ratio + '%' : '—' }} {{ progressData.hasData && progressData.ratio != null ? progressData.ratio + '%' : '无数据' }}
</span> </span>
</div> </div>
<!-- KPI实际值快照:当前值 / 目标值 / 达成率 --> <!-- KPI实际值快照:当前值 / 目标值 / 达成率 -->
@@ -242,6 +243,8 @@ function getKpiName(code: string): string {
} }
function getKpiLevel(item: any): string { function getKpiLevel(item: any): string {
// 后端 /maps/{id}/review 已按方向(含越低越好)判级:优先使用,避免前端重复口径
if (item.level) return item.level
if (item.actual != null && item.target) { if (item.actual != null && item.target) {
const ratio = item.actual / item.target const ratio = item.actual / item.target
if (ratio >= 0.9) return 'green' if (ratio >= 0.9) return 'green'
@@ -251,9 +254,10 @@ function getKpiLevel(item: any): string {
return 'gray' return 'gray'
} }
/** KPI达成率文本(绿≥90% / 黄60-90% / 红<60%) */ /** KPI达成率文本(后端 ratio 已按指标方向计算;无数据显式标注) */
function kpiRateText(item: any): string { function kpiRateText(item: any): string {
if (item.actual == null || !item.target) return '—' if (item.ratio != null) return Math.round(item.ratio) + '%'
if (item.actual == null || !item.target) return '无数据'
const ratio = item.actual / item.target const ratio = item.actual / item.target
return Math.round(ratio * 100) + '%' return Math.round(ratio * 100) + '%'
} }
@@ -304,7 +308,7 @@ function getKrProgressText(kr: any): string {
<style scoped> <style scoped>
.map-node { .map-node {
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; background: var(--bh-bg-card); border: 1px solid #e0e0e0; border-radius: 8px;
padding: 10px; margin-bottom: 8px; cursor: pointer; padding: 10px; margin-bottom: 8px; cursor: pointer;
transition: all 0.2s; position: relative; transition: all 0.2s; position: relative;
} }
@@ -348,7 +352,7 @@ function getKrProgressText(kr: any): string {
.node-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; } .node-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
.node-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; } .node-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
.node-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; } .node-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.node-desc { font-size: 12px; color: #888; margin-bottom: 4px; } .node-desc { font-size: 12px; color: var(--bh-text-tertiary); margin-bottom: 4px; }
.node-kpis { display: flex; flex-wrap: wrap; gap: 2px; } .node-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
.node-kpi-snapshots { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; } .node-kpi-snapshots { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
@@ -359,8 +363,8 @@ function getKrProgressText(kr: any): string {
.snap-green { background: #f0f9eb; } .snap-green { background: #f0f9eb; }
.snap-yellow { background: #fdf6ec; } .snap-yellow { background: #fdf6ec; }
.snap-red { background: #fef0f0; } .snap-red { background: #fef0f0; }
.snap-gray { background: #f5f5f5; } .snap-gray { background: var(--bh-bg-main); }
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 92px; color: #666; } .snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 92px; color: var(--bh-text-secondary); }
.snap-value { .snap-value {
font-weight: 600; color: #333; white-space: nowrap; font-weight: 600; color: #333; white-space: nowrap;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -370,7 +374,7 @@ function getKrProgressText(kr: any): string {
.snap-now-yellow { color: #e6a23c; } .snap-now-yellow { color: #e6a23c; }
.snap-now-red { color: #f56c6c; } .snap-now-red { color: #f56c6c; }
.snap-now-gray { color: #333; } .snap-now-gray { color: #333; }
.snap-target { font-weight: 400; color: #999; font-size: 10px; } .snap-target { font-weight: 400; color: var(--bh-text-tertiary); font-size: 10px; }
.snap-rate { .snap-rate {
font-size: 10px; font-weight: 700; flex-shrink: 0; min-width: 34px; text-align: right; font-size: 10px; font-weight: 700; flex-shrink: 0; min-width: 34px; text-align: right;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -379,9 +383,9 @@ function getKrProgressText(kr: any): string {
.rate-green { color: #67c23a; } .rate-green { color: #67c23a; }
.rate-yellow { color: #e6a23c; } .rate-yellow { color: #e6a23c; }
.rate-red { color: #f56c6c; } .rate-red { color: #f56c6c; }
.rate-gray { color: #bbb; } .rate-gray { color: var(--bh-text-tertiary); }
.kpi-snapshot-more { font-size: 10px; color: #409eff; text-align: center; cursor: pointer; padding: 1px; } .kpi-snapshot-more { font-size: 10px; color: #409eff; text-align: center; cursor: pointer; padding: 1px; }
.node-click-hint { font-size: 10px; color: #bbb; text-align: center; margin-top: 2px; } .node-click-hint { font-size: 10px; color: var(--bh-text-tertiary); text-align: center; margin-top: 2px; }
.node-plan-summary { .node-plan-summary {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
@@ -403,7 +407,7 @@ function getKrProgressText(kr: any): string {
justify-content: flex-end; justify-content: flex-end;
} }
.node-progress-label { .node-progress-label {
font-size: 10px; color: #909399; flex-shrink: 0; line-height: 1; font-size: 10px; color: var(--bh-text-secondary); flex-shrink: 0; line-height: 1;
} }
.node-progress-track { .node-progress-track {
flex: 1; max-width: 90px; height: 6px; background: #e4e7ed; flex: 1; max-width: 90px; height: 6px; background: #e4e7ed;
@@ -476,7 +480,7 @@ function getKrProgressText(kr: any): string {
margin-bottom: 4px; margin-bottom: 4px;
border-radius: 6px; border-radius: 6px;
border: 1px solid #e8f0fe; border: 1px solid #e8f0fe;
background: #f8faff; background: var(--bh-bg-main);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
} }
@@ -513,7 +517,7 @@ function getKrProgressText(kr: any): string {
} }
.kr-sub-target { .kr-sub-target {
font-size: 10px; font-size: 10px;
color: #909399; color: var(--bh-text-secondary);
flex-shrink: 0; flex-shrink: 0;
} }
/* KR 删除入口:hover 才显形,避免与 KR 文本拥挤;键盘聚焦时同样可见(可访问性) */ /* KR 删除入口:hover 才显形,避免与 KR 文本拥挤;键盘聚焦时同样可见(可访问性) */
@@ -528,7 +532,7 @@ function getKrProgressText(kr: any): string {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 4px; border-radius: 4px;
background: transparent; background: transparent;
color: #c0c4cc; color: var(--bh-placeholder);
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
transition: opacity .15s, color .15s, background .15s, border-color .15s; transition: opacity .15s, color .15s, background .15s, border-color .15s;
@@ -571,14 +575,14 @@ function getKrProgressText(kr: any): string {
.kr-sub-progress-text { .kr-sub-progress-text {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: #606266; color: var(--bh-text-regular);
min-width: 28px; min-width: 28px;
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.kr-sub-weight { .kr-sub-weight {
font-size: 10px; font-size: 10px;
color: #909399; color: var(--bh-text-secondary);
background: #f0f5ff; background: #f0f5ff;
padding: 1px 6px; padding: 1px 6px;
border-radius: 8px; border-radius: 8px;
@@ -590,7 +594,7 @@ function getKrProgressText(kr: any): string {
gap: 8px; gap: 8px;
margin-top: 6px; margin-top: 6px;
padding: 8px; padding: 8px;
color: #909399; color: var(--bh-text-secondary);
font-size: 12px; font-size: 12px;
border-top: 1px dashed #e4e7ed; border-top: 1px dashed #e4e7ed;
} }
@@ -0,0 +1,338 @@
<template>
<aside class="node-detail" aria-label="目标详情">
<header class="nd-head">
<div class="nd-title-wrap">
<span class="nd-layer" :style="{ color: layerColor, borderColor: layerColor }">{{ layerLabel }}</span>
<h4 class="nd-title">{{ obj?.name || '未命名目标' }}</h4>
<div class="nd-sub">
<span class="nd-dot" :class="'dot-' + (progress?.level || 'gray')"></span>
<span>{{ levelText(progress?.level) }}</span>
<span class="dot-sep">·</span>
<span>负责人 {{ obj?.owner || '未设置' }}</span>
</div>
</div>
<button type="button" class="nd-close" aria-label="关闭详情" @click="$emit('close')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</header>
<div class="nd-body">
<section class="nd-rate-card">
<div class="nd-rate-label">达成率</div>
<div class="nd-rate-value mono" :class="rateClass">
<template v-if="hasRate">{{ Math.round(progress!.ratio) }}<span class="unit">%</span></template>
<template v-else>无数据</template>
</div>
<div class="nd-rate-note">
<template v-if="hasRate">
由 {{ progress?.kpiWithData }} / {{ progress?.kpiTotal ?? progress?.kpiList?.length ?? 0 }} 个有实测值的 KPI 均值计算
</template>
<template v-else>该目标暂无同时具备目标值与实测值的 KPI</template>
</div>
</section>
<section v-if="obj?.description" class="nd-block">
<div class="nd-block-title">目标说明</div>
<p class="nd-desc">{{ obj.description }}</p>
</section>
<section class="nd-block">
<div class="nd-block-title">
关联 KPI
<span class="nd-count">{{ kpiList.length }}</span>
<span v-if="trendLoading" class="nd-loading">趋势加载中…</span>
</div>
<div v-if="kpiList.length === 0" class="nd-empty">
<p>该目标未关联 KPI,达成率无法计算。</p>
<div class="nd-empty-actions">
<el-button size="small" type="primary" plain @click="$emit('edit')">编辑目标并关联 KPI</el-button>
<el-button size="small" text @click="$emit('go-kpi-library')">去 KPI 库</el-button>
</div>
</div>
<ul v-else class="kpi-list">
<li v-for="k in kpiList" :key="k.code" class="kpi-item">
<div class="kpi-top">
<span class="kpi-dot" :class="'dot-' + (k.level || 'gray')"></span>
<button type="button" class="kpi-name" :title="'查看 KPI:' + (k.name || k.code)" @click="$emit('open-kpi', k.code)">
{{ k.name || k.code }}
</button>
<span class="kpi-rate mono" :class="'rate-' + (k.ratio != null ? levelOf(k.ratio) : 'gray')">
{{ k.ratio != null ? Math.round(k.ratio) + '%' : '无数据' }}
</span>
</div>
<div class="kpi-meta">
<span>目标 {{ fmtNum(k.target, k.unit) }}</span>
<span class="dot-sep">/</span>
<span>实际 {{ fmtNum(k.actual, k.unit) }}</span>
<span class="kpi-trend" :class="'trend-' + (trendOf(k.code)?.dir || 'none')">
<template v-if="trendOf(k.code)?.dir === 'up'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg>
</template>
<template v-else-if="trendOf(k.code)?.dir === 'down'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
</template>
<template v-else-if="trendOf(k.code)?.dir === 'flat'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14" /></svg>
</template>
<span v-if="trendOf(k.code)?.dir">
较上期 {{ trendOf(k.code)?.deltaPct != null ? (trendOf(k.code)!.deltaPct! > 0 ? '+' : '') + trendOf(k.code)!.deltaPct!.toFixed(1) + '%' : '' }}
</span>
<span v-else-if="!trendLoading">暂无趋势数据</span>
</span>
</div>
</li>
</ul>
</section>
<section class="nd-block">
<div class="nd-block-title">因果上下游</div>
<div class="causal-row">
<span class="causal-label">被驱动</span>
<div class="causal-items">
<template v-if="upstream.length">
<button
v-for="u in upstream" :key="'u-' + u.key" type="button"
class="causal-chip" @click="$emit('select-key', u.key)"
>{{ u.name }}</button>
</template>
<span v-else class="causal-none">无上游连线</span>
</div>
</div>
<div class="causal-row">
<span class="causal-label">驱动</span>
<div class="causal-items">
<template v-if="downstream.length">
<button
v-for="d in downstream" :key="'d-' + d.key" type="button"
class="causal-chip" @click="$emit('select-key', d.key)"
>{{ d.name }}</button>
</template>
<span v-else class="causal-none">无下游连线</span>
</div>
</div>
</section>
<section class="nd-block">
<div class="nd-block-title">行动方案</div>
<div v-if="planSummary && planSummary.total > 0" class="plan-row">
<span class="mono plan-total">{{ planSummary.total }}</span>
<span>项行动方案</span>
<span v-if="planSummary.pending > 0" class="plan-pending">待办 {{ planSummary.pending }}</span>
<el-button size="small" text @click="$emit('open-plan')">查看</el-button>
</div>
<div v-else class="nd-empty-inline">
<span>暂无关联行动方案</span>
<el-button size="small" text @click="$emit('open-plan')">去创建</el-button>
</div>
</section>
</div>
<footer class="nd-foot">
<el-button size="small" @click="$emit('edit')">编辑目标</el-button>
<el-button size="small" text @click="$emit('open-kpi-detail')" :disabled="kpiList.length === 0">KPI 明细</el-button>
</footer>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface TrendInfo { dir: 'up' | 'down' | 'flat' | null; deltaPct: number | null; period?: string | null }
const props = defineProps<{
obj: any
dimKey: string
index: number
layerLabel: string
layerColor: string
progress: any
connections: any[]
nodeLabelMap: Record<string, string>
trendMap: Record<string, TrendInfo>
trendLoading?: boolean
}>()
defineEmits<{
(e: 'close'): void
(e: 'edit'): void
(e: 'open-kpi-detail'): void
(e: 'open-kpi', code: string): void
(e: 'open-plan'): void
(e: 'select-key', key: string): void
(e: 'go-kpi-library'): void
}>()
const nodeKey = computed(() => `${props.dimKey}-${props.index}`)
const kpiList = computed<any[]>(() => props.progress?.kpiList || [])
const planSummary = computed<any>(() => props.progress?.planSummary || null)
const hasRate = computed(() => props.progress?.hasData === true && props.progress?.ratio != null)
const rateClass = computed(() => {
if (!hasRate.value) return 'rate-gray'
const r = props.progress.ratio
return r >= 90 ? 'rate-green' : r >= 60 ? 'rate-yellow' : 'rate-red'
})
const upstream = computed(() => linkNames('up'))
const downstream = computed(() => linkNames('down'))
function linkNames(dir: 'up' | 'down') {
const list = (props.connections || []).filter((c: any) =>
dir === 'up' ? c.to === nodeKey.value && c.from !== nodeKey.value : c.from === nodeKey.value && c.to !== nodeKey.value
)
const seen = new Set<string>()
const out: { key: string; name: string }[] = []
for (const c of list) {
const k = dir === 'up' ? c.from : c.to
if (!k || seen.has(k)) continue
seen.add(k)
out.push({ key: k, name: props.nodeLabelMap[k] || k })
}
return out
}
function levelOf(ratio: number): string {
return ratio >= 90 ? 'green' : ratio >= 60 ? 'yellow' : 'red'
}
function levelText(level?: string): string {
if (level === 'green') return '达标'
if (level === 'yellow') return '关注'
if (level === 'red') return '风险'
return '无数据'
}
function trendOf(code: string): TrendInfo | undefined {
return props.trendMap?.[code]
}
function fmtNum(v: any, unit?: string | null): string {
if (v == null || v === '') return '无数据'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
const text = Math.abs(n) >= 10000
? (n / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 2 }) + ' 万'
: n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
return unit ? `${text} ${unit}` : text
}
</script>
<style scoped>
.node-detail {
width: 320px;
flex: 0 0 320px;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bh-bg-card, #fff);
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
}
.nd-head {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px 10px;
border-bottom: 1px solid #E5E7EB;
flex-shrink: 0;
}
.nd-title-wrap { flex: 1; min-width: 0; }
.nd-layer {
display: inline-block;
font-size: 10px;
line-height: 16px;
padding: 0 6px;
border: 1px solid currentColor;
border-radius: 4px;
margin-bottom: 4px;
}
.nd-title { margin: 0; font-size: 14px; font-weight: 700; color: #0F172A; line-height: 1.35; }
.nd-sub { margin-top: 3px; font-size: 11px; color: #94A3B8; display: flex; align-items: center; gap: 4px; }
.nd-dot { width: 7px; height: 7px; border-radius: 50%; }
.dot-green { background: #00C896; }
.dot-yellow { background: #F59E0B; }
.dot-red { background: #EF4444; }
.dot-gray { background: #CBD5E1; }
.dot-sep { color: #CBD5E1; }
.nd-close {
border: 0; background: transparent; color: #94A3B8; cursor: pointer;
width: 22px; height: 22px; padding: 2px; border-radius: 4px;
}
.nd-close:hover { background: #F6F7F9; color: #475569; }
.nd-close svg { width: 14px; height: 14px; }
.nd-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 14px 12px; }
.nd-rate-card {
background: #F8FAFC;
border: 1px solid #EEF1F5;
border-radius: 8px;
padding: 10px 12px;
}
.nd-rate-label { font-size: 11px; color: #64748B; }
.nd-rate-value {
font-size: 26px;
font-weight: 700;
line-height: 1.2;
margin-top: 2px;
}
.nd-rate-value .unit { font-size: 12px; font-weight: 500; margin-left: 2px; }
.rate-green { color: #00C896; }
.rate-yellow { color: #F59E0B; }
.rate-red { color: #EF4444; }
.rate-gray { color: #94A3B8; font-size: 16px; }
.nd-rate-note { margin-top: 4px; font-size: 11px; color: #94A3B8; line-height: 1.45; }
.nd-block { margin-top: 12px; }
.nd-block-title {
display: flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 700; color: #475569; margin-bottom: 6px;
}
.nd-count {
font-size: 10px; font-weight: 500; color: #64748B;
background: #F1F5F9; border-radius: 8px; padding: 0 6px; line-height: 16px;
}
.nd-loading { margin-left: auto; font-size: 10px; font-weight: 400; color: #94A3B8; }
.nd-desc { margin: 0; font-size: 12px; color: #64748B; line-height: 1.55; }
.nd-empty { padding: 8px 10px; background: #FFF8EE; border: 1px solid #FDE7C3; border-radius: 6px; }
.nd-empty p { margin: 0 0 6px; font-size: 12px; color: #B45309; }
.nd-empty-actions { display: flex; align-items: center; gap: 4px; }
.kpi-list { list-style: none; margin: 0; padding: 0; }
.kpi-item { padding: 6px 0; border-bottom: 1px dashed #EEF1F5; }
.kpi-item:last-child { border-bottom: 0; }
.kpi-top { display: flex; align-items: center; gap: 5px; }
.kpi-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.kpi-name {
flex: 1; min-width: 0; border: 0; background: transparent; padding: 0;
font-size: 12px; color: #0F172A; text-align: left; cursor: pointer;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.kpi-name:hover { color: #635BFF; text-decoration: underline; }
.kpi-rate { font-size: 12px; font-weight: 700; }
.rate-green { color: #00C896; } .rate-yellow { color: #F59E0B; } .rate-red { color: #EF4444; } .rate-gray { color: #94A3B8; font-weight: 500; }
.kpi-meta {
display: flex; align-items: center; gap: 5px;
margin: 2px 0 0 11px; font-size: 11px; color: #94A3B8;
}
.kpi-trend { display: inline-flex; align-items: center; gap: 2px; margin-left: auto; color: #94A3B8; }
.kpi-trend svg { width: 11px; height: 11px; }
.trend-up { color: #0EA5E9; }
.trend-down { color: #F59E0B; }
.causal-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; }
.causal-label { flex: 0 0 44px; font-size: 11px; color: #94A3B8; line-height: 22px; }
.causal-items { display: flex; flex-wrap: wrap; gap: 4px; }
.causal-chip {
border: 1px solid #E5E7EB; background: #fff; color: #475569;
font-size: 11px; line-height: 20px; padding: 0 8px; border-radius: 4px; cursor: pointer;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.causal-chip:hover { border-color: #635BFF; color: #635BFF; }
.causal-none { font-size: 11px; color: #CBD5E1; line-height: 22px; }
.plan-row { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #475569; }
.plan-total { font-size: 16px; font-weight: 700; color: #0F172A; }
.plan-pending { font-size: 11px; color: #F59E0B; background: #FFF8EE; border-radius: 4px; padding: 0 6px; }
.nd-empty-inline { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #94A3B8; }
.nd-foot {
flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px;
padding: 8px 12px; border-top: 1px solid #E5E7EB; background: #FCFCFD;
}
.mono { font-family: 'IBM Plex Mono', Consolas, monospace; font-variant-numeric: tabular-nums; }
</style>
@@ -0,0 +1,254 @@
<template>
<section class="map-summary" aria-label="战略地图摘要">
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
<div class="sum-card sum-card--wide">
<div class="sum-head">
<span class="sum-label">营业收入</span>
<span v-if="revenue && revenue.caliber_label" class="sum-badge calm">{{ revenue.caliber_label }}</span>
</div>
<div v-if="revenueLines.length" class="rev-lines">
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
<span class="rev-line-label">{{ ln.label }}</span>
<span class="rev-line-value mono" :class="{ muted: ln.amount == null }">
<template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="sum-unit">万元</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</span>
</div>
<div class="rev-line rev-line--total">
<span class="rev-line-label">{{ revenueTotalLabel }}</span>
<span class="rev-line-value mono">
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="sum-unit">万元</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</span>
</div>
</div>
<div v-else class="sum-value muted">—<i class="sum-unit">无数据</i></div>
<div class="sum-sub">{{ revenuePeriod }}</div>
</div>
<!-- ② 毛利率:新式口径(折扣后净进价),历史缺票期带标识 -->
<div class="sum-card">
<div class="sum-head">
<span class="sum-label">毛利率</span>
<span v-if="grossMargin && grossMargin.data_tag" class="sum-badge warn">{{ grossMargin.data_tag }}</span>
</div>
<div class="sum-value mono" :class="{ muted: gmValue == null }">
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="sum-unit">%</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">{{ gmSub }}</div>
</div>
<!-- ③ 战略目标达成率 -->
<div class="sum-card">
<div class="sum-head"><span class="sum-label">战略目标达成率</span></div>
<div class="sum-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="sum-unit">%</i></template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">
<template v-if="achievement != null">{{ achievementCount }} 个目标有实测值 · 其余无数据</template>
<template v-else>所有目标均无可用 KPI 实测值</template>
</div>
</div>
<!-- ④ 风险目标数 -->
<div class="sum-card">
<div class="sum-head"><span class="sum-label">风险目标</span></div>
<div class="sum-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
<template v-if="riskCount != null">{{ riskCount }}</template>
<template v-else>—<i class="sum-unit">无数据</i></template>
</div>
<div class="sum-sub">
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 共 {{ summary.total }} 个目标</template>
<template v-else>战略回顾数据未加载</template>
</div>
</div>
<!-- ⑤ 口径变更标识位 + 管理指标(非会计科目,单列、不占主位) -->
<div class="sum-foot">
<span v-if="switchInfo && switchInfo.has_switch" class="sum-badge warn" :title="switchInfo.note || ''">
{{ switchInfo.label }}<template v-if="switchInfo.effective_date"> · 自 {{ switchInfo.effective_date }}</template>
<em v-if="switchInfo.comparability"> · {{ switchInfo.comparability }}</em>
</span>
<span v-for="m in management" :key="m.key" class="mgmt" :title="m.caliber_note || ''">
<span class="mgmt-label">{{ m.label }}</span>
<span class="mgmt-value mono">{{ m.value == null ? '—' : m.value.toFixed(2) + '%' }}</span>
<span class="mgmt-tag">{{ m.tag }}</span>
<span v-if="m.abnormal" class="mgmt-abnormal">异常·须核查</span>
<span class="mgmt-caliber">{{ fmtNum(m.numerator) }} ÷ {{ fmtNum(m.denominator) }}<template v-if="m.period"> · {{ m.period }}</template></span>
</span>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null }
const props = defineProps<{
revenue: {
available?: boolean
caliber_label?: string
unit?: string
period?: string | null
lines?: RevLine[]
total?: number | null
total_label?: string
} | null
grossMargin: {
available?: boolean
value: number | null
caliber_label?: string
data_tag?: string
period?: string | null
note?: string
} | null
management?: Array<{
key: string
label: string
value: number | null
unit?: string
numerator?: number | null
denominator?: number | null
period?: string | null
tag?: string
abnormal?: boolean
caliber_note?: string
}>
achievement: number | null
achievementCount: number
summary: { total: number; green: number; yellow: number; red: number; health_score?: number } | null
switchInfo?: { has_switch?: boolean; label?: string; effective_date?: string | null; comparability?: string; note?: string } | null
loading?: boolean
}>()
const revenueLines = computed<RevLine[]>(() => (props.revenue?.lines || []).filter(l => !!l && !!l.key))
/** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */
const revenueTotal = computed<number | null>(() => {
const v = props.revenue?.total
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
})
const revenueTotalLabel = computed(() => props.revenue?.total_label || '合计(复算校验)')
const revenuePeriod = computed(() => {
const p = props.revenue?.period
if (!p) return '凭证期未取到 · 无数据'
return p + ' 实际值 · 各业务线全额售价分列(禁净额、禁合并)'
})
const gmValue = computed<number | null>(() => {
const v = props.grossMargin?.value
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
})
const gmSub = computed(() => {
if (gmValue.value == null) return '财务口径未取到 · 无数据'
const p = props.grossMargin?.period || ''
const cal = props.grossMargin?.caliber_label || ''
return [p ? p + ' 实际值' : '', cal].filter(Boolean).join(' · ')
})
const riskCount = computed<number | null>(() => (props.summary ? props.summary.red : null))
const achievementLevel = computed(() => {
const a = props.achievement
if (a == null) return 'gray'
if (a >= 90) return 'green'
if (a >= 60) return 'yellow'
return 'red'
})
/** 千分位展示;null ⇒ 「—」,不做任何兜底造数 */
function fmtNum(v: number | null | undefined): string {
if (v == null || Number.isNaN(Number(v))) return '—'
return Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 2, minimumFractionDigits: 0 })
}
</script>
<style scoped>
.map-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
flex-shrink: 0;
margin-bottom: 10px;
}
.sum-card {
background: var(--bh-bg-card, #fff);
border: 1px solid var(--bh-border, #E5E7EB);
border-radius: 8px;
padding: 10px 14px;
min-width: 0;
}
.sum-card--wide { grid-column: span 2; }
.sum-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
.sum-label {
font-size: 12px;
color: var(--bh-text-secondary, #64748B);
letter-spacing: .02em;
white-space: nowrap;
}
.sum-badge {
font-size: 10px;
line-height: 1.5;
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sum-badge.calm { color: #635BFF; background: rgba(99, 91, 255, .08); }
.sum-badge.warn { color: #B45309; background: #FEF3C7; }
.sum-badge.warn em { font-style: normal; }
.sum-value {
margin-top: 4px;
font-size: 22px;
font-weight: 700;
line-height: 1.2;
color: var(--bh-text-primary, #0F172A);
display: flex;
align-items: baseline;
gap: 3px;
}
.sum-value.muted { font-size: 15px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
.sum-unit { font-size: 11px; font-weight: 500; font-style: normal; color: var(--bh-text-secondary, #64748B); margin-left: 2px; }
.sum-sub {
margin-top: 4px;
font-size: 11px;
color: var(--bh-text-muted, #94A3B8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rev-lines { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.rev-line-label { font-size: 12px; color: var(--bh-text-secondary, #64748B); }
.rev-line-value { font-size: 18px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
.rev-line-value.muted { font-size: 14px; font-weight: 500; color: var(--bh-text-muted, #94A3B8); }
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); padding-top: 3px; margin-top: 2px; }
.rev-line--total .rev-line-label,
.rev-line--total .rev-line-value { font-size: 12px; font-weight: 500; color: var(--bh-text-secondary, #64748B); }
.sum-foot {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 16px;
padding: 6px 12px;
border: 1px dashed var(--bh-border, #E5E7EB);
border-radius: 6px;
font-size: 11px;
color: var(--bh-text-muted, #94A3B8);
}
.mgmt { display: inline-flex; align-items: baseline; gap: 6px; }
.mgmt-label { color: var(--bh-text-secondary, #64748B); }
.mgmt-value { font-size: 13px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
.mgmt-tag { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: #F1F5F9; color: #475569; }
.mgmt-abnormal { color: #B91C1C; }
.mgmt-caliber { color: var(--bh-text-muted, #94A3B8); }
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
.lv-green { color: #00C896; }
.lv-yellow { color: #F59E0B; }
.lv-red { color: #EF4444; }
@media (max-width: 1200px) {
.map-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sum-card--wide { grid-column: span 2; }
}
</style>
@@ -0,0 +1,297 @@
<template>
<aside class="map-tree" aria-label="目标树">
<div class="tree-head">
<span class="tree-title">目标树</span>
<span class="tree-total">{{ visibleCount }} / {{ totalCount }} 个目标</span>
</div>
<div class="tree-scroll">
<section v-for="g in groups" :key="g.key" class="tree-group">
<button type="button" class="group-head" @click="$emit('toggle-group', g.key)">
<svg
class="chev" :class="{ folded: collapsed[g.key] }"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"
><path d="M6 9l6 6 6-6" /></svg>
<span class="group-dot" :style="{ background: g.color }"></span>
<span class="group-label">{{ g.label }}</span>
<span class="group-count">{{ g.objectives.length }}</span>
</button>
<div v-if="!collapsed[g.key]" class="group-body">
<div v-if="g.objectives.length === 0" class="group-empty">
<span>{{ search || filter !== 'all' ? '无匹配目标' : '该层暂无目标' }}</span>
<button type="button" class="link-btn" @click="$emit('add-objective', g.key)">去创建</button>
</div>
<div
v-for="o in g.objectives"
:key="o.key"
class="tree-node"
:class="{ active: activeKey === o.key, dimmed: !!search }"
draggable="true"
:title="'拖到画布中的另一个目标上可建立因果连线'"
@dragstart="onDragStart($event, o)"
@dragend="$emit('drag-end')"
@click="$emit('select', o)"
>
<div class="tn-row">
<button
v-if="o.krs && o.krs.length"
type="button"
class="kr-chev"
:class="{ open: expanded[o.key] }"
:aria-label="expanded[o.key] ? '收起关键结果' : '展开关键结果'"
@click.stop="toggleKr(o.key)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
</button>
<span class="tn-level" :class="'dot-' + (o.level || 'gray')" :title="levelText(o.level)"></span>
<span class="tn-name" :title="o.name">{{ o.name }}</span>
</div>
<div class="tn-meta">
<span class="tn-rate mono" :class="rateClass(o)">{{ o.hasData && o.ratio != null ? Math.round(o.ratio) + '%' : '无数据' }}</span>
<span class="tn-sep">·</span>
<span>{{ o.kpiCount }} KPI</span>
<span class="tn-sep">·</span>
<span class="tn-owner" :title="o.owner">{{ o.owner || '未设负责人' }}</span>
</div>
<ul v-if="expanded[o.key] && o.krs && o.krs.length" class="kr-list" @click.stop>
<li v-for="(kr, ki) in o.krs" :key="ki" class="kr-item">
<span class="kr-idx mono">{{ ki + 1 }}</span>
<span class="kr-name" :title="kr.name">{{ kr.name || '未命名 KR' }}</span>
<span v-if="kr.target_value != null && kr.target_value !== ''" class="kr-target mono">目标 {{ kr.target_value }}</span>
</li>
</ul>
</div>
</div>
</section>
<div v-if="totalCount === 0" class="tree-empty">
<p>当前地图还没有任何目标</p>
<button type="button" class="link-btn" @click="$emit('add-objective', groups[0]?.key)">
去创建第一个目标
</button>
</div>
</div>
<div class="tree-foot">
<svg class="foot-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M12 3v6M12 15v6M3 12h6M15 12h6" /><circle cx="12" cy="12" r="3" />
</svg>
拖拽目标到画布可建立因果连线
</div>
</aside>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
interface TreeObjective {
key: string
index: number
name: string
owner?: string
level?: string
ratio: number | null
hasData: boolean
kpiCount: number
krs?: any[]
raw?: any
dimKey?: string
}
interface TreeGroup {
key: string
label: string
color: string
objectives: TreeObjective[]
}
const props = defineProps<{
groups: TreeGroup[]
search: string
filter: 'all' | 'risk' | 'nodata'
activeKey: string | null
collapsed: Record<string, boolean>
visibleCount: number
totalCount: number
}>()
const emit = defineEmits<{
(e: 'select', o: any): void
(e: 'toggle-group', key: string): void
(e: 'add-objective', key?: string): void
(e: 'drag-start', payload: { dimKey: string; index: number; obj: any; event: DragEvent }): void
(e: 'drag-end'): void
}>()
const expanded = reactive<Record<string, boolean>>({})
function toggleKr(key: string) {
expanded[key] = !expanded[key]
}
function rateClass(o: TreeObjective) {
if (!o.hasData || o.ratio == null) return 'rate-gray'
if (o.ratio >= 90) return 'rate-green'
if (o.ratio >= 60) return 'rate-yellow'
return 'rate-red'
}
function levelText(level?: string) {
if (level === 'green') return '达标'
if (level === 'yellow') return '关注'
if (level === 'red') return '风险'
return '无数据'
}
function onDragStart(e: DragEvent, o: TreeObjective) {
if (!o.dimKey) return
try {
e.dataTransfer?.setData('text/plain', o.key)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'link'
} catch { /* 兼容受限浏览器 */ }
emit('drag-start', { dimKey: o.dimKey, index: o.index, obj: o.raw, event: e })
}
</script>
<style scoped>
.map-tree {
width: 272px;
flex: 0 0 272px;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bh-bg-card, #fff);
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
}
.tree-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 10px 12px 6px;
flex-shrink: 0;
}
.tree-title { font-size: 13px; font-weight: 700; color: #0F172A; }
.tree-total { font-size: 11px; color: #94A3B8; }
.tree-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 8px; }
.tree-group { margin-bottom: 6px; }
.group-head {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px;
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: #475569;
text-align: left;
}
.group-head:hover { background: #F6F7F9; }
.group-dot { width: 8px; height: 8px; border-radius: 2px; }
.group-label { flex: 1; font-weight: 600; }
.group-count { font-size: 11px; color: #94A3B8; }
.chev { width: 12px; height: 12px; transition: transform .15s; color: #94A3B8; }
.chev.folded { transform: rotate(-90deg); }
.group-body { padding: 2px 0 4px; }
.group-empty {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
font-size: 11px;
color: #94A3B8;
}
.link-btn {
border: 0;
background: transparent;
color: #635BFF;
font-size: 11px;
cursor: pointer;
padding: 0;
}
.link-btn:hover { text-decoration: underline; }
.tree-node {
padding: 6px 8px 6px 10px;
border-radius: 6px;
border-left: 3px solid transparent;
cursor: pointer;
transition: background .12s;
}
.tree-node:hover { background: #F6F7F9; }
.tree-node.active { background: #F1F0FF; border-left-color: #635BFF; }
.tn-row { display: flex; align-items: center; gap: 5px; }
.tn-level { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot-green { background: #00C896; }
.dot-yellow { background: #F59E0B; }
.dot-red { background: #EF4444; }
.dot-gray { background: #CBD5E1; }
.tn-name {
font-size: 12.5px;
color: #0F172A;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tn-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
padding-left: 12px;
font-size: 11px;
color: #94A3B8;
white-space: nowrap;
overflow: hidden;
}
.tn-owner { overflow: hidden; text-overflow: ellipsis; }
.tn-rate { font-weight: 600; }
.rate-green { color: #00C896; }
.rate-yellow { color: #F59E0B; }
.rate-red { color: #EF4444; }
.rate-gray { color: #94A3B8; font-weight: 500; }
.kr-chev {
border: 0;
background: transparent;
padding: 0;
width: 12px;
height: 12px;
color: #94A3B8;
cursor: pointer;
flex-shrink: 0;
}
.kr-chev svg { width: 12px; height: 12px; transition: transform .15s; }
.kr-chev.open svg { transform: rotate(90deg); }
.kr-list { list-style: none; margin: 4px 0 0 19px; padding: 0; }
.kr-item {
display: flex;
align-items: baseline;
gap: 5px;
font-size: 11px;
color: #64748B;
line-height: 1.5;
}
.kr-idx { color: #94A3B8; }
.kr-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kr-target { color: #94A3B8; }
.tree-empty { padding: 20px 10px; text-align: center; font-size: 12px; color: #94A3B8; }
.tree-empty p { margin: 0 0 6px; }
.tree-foot {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
border-top: 1px solid #E5E7EB;
font-size: 11px;
color: #94A3B8;
}
.foot-ico { width: 12px; height: 12px; }
.mono { font-family: 'IBM Plex Mono', Consolas, monospace; font-variant-numeric: tabular-nums; }
</style>
@@ -1,6 +1,17 @@
<template> <template>
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }"> <div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
<div class="layer-header" :style="{ background: color }"> <div class="layer-header" :style="{ background: color }">
<button
type="button"
class="layer-collapse-btn"
:aria-label="collapsed ? '展开该层' : '收起该层'"
:title="collapsed ? '展开该层' : '收起该层'"
@click.stop="$emit('toggle-collapse')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path :d="collapsed ? 'M9 6l6 6-6 6' : 'M6 9l6 6 6-6'" />
</svg>
</button>
<span class="layer-icon">{{ icon }}</span> <span class="layer-icon">{{ icon }}</span>
<span class="layer-name">{{ label }}</span> <span class="layer-name">{{ label }}</span>
<span class="layer-objective-count">{{ objectives.length }} 个目标</span> <span class="layer-objective-count">{{ objectives.length }} 个目标</span>
@@ -15,7 +26,7 @@
</template> </template>
</el-dropdown> </el-dropdown>
</div> </div>
<div class="layer-body" ref="bodyRef"> <div v-show="!collapsed" class="layer-body" ref="bodyRef">
<!-- vuedraggable: 每层独立group → 禁止跨层拖拽 --> <!-- vuedraggable: 每层独立group → 禁止跨层拖拽 -->
<draggable <draggable
v-if="objectives.length > 0" v-if="objectives.length > 0"
@@ -23,6 +34,7 @@
:group="layerKey" :group="layerKey"
item-key="name" item-key="name"
:animation="150" :animation="150"
@start="onDragStart"
@end="onDragEnd" @end="onDragEnd"
class="layer-draggable" class="layer-draggable"
> >
@@ -44,7 +56,7 @@
:icon-map="iconMap" :icon-map="iconMap"
:kpi-name-map="kpiNameMap" :kpi-name-map="kpiNameMap"
:all-kpis="allKpis" :all-kpis="allKpis"
:chain-dimmed="chainNodeKeys.length > 0 && !chainNodeKeys.includes(`${layerKey}-${index}`)" :chain-dimmed="isDimmed(`${layerKey}-${index}`)"
@click="onNodeClick(layerKey, index, element)" @click="onNodeClick(layerKey, index, element)"
@edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })" @edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })"
@delete="$emit('delete-objective', { dimKey: layerKey, idx: index })" @delete="$emit('delete-objective', { dimKey: layerKey, idx: index })"
@@ -62,9 +74,9 @@
<!-- 空状态占位提示 --> <!-- 空状态占位提示 -->
<div v-if="objectives.length === 0" class="layer-empty"> <div v-if="objectives.length === 0" class="layer-empty">
<el-empty description="暂无目标" :image-size="60"> <el-empty description="该层暂无目标" :image-size="60">
<el-button type="primary" size="small" @click="$emit('add-objective', layerKey)"> <el-button type="primary" size="small" @click="$emit('add-objective', layerKey)">
点击 + 添加目标 去创建目标
</el-button> </el-button>
</el-empty> </el-empty>
</div> </div>
@@ -98,6 +110,11 @@ const props = defineProps<{
allKpis?: any[] allKpis?: any[]
/** 因果链查看模式下,不在链上的节点变淡 */ /** 因果链查看模式下,不在链上的节点变淡 */
chainNodeKeys?: string[] chainNodeKeys?: string[]
/** 搜索/筛选激活时:不在匹配集合内的节点变淡 */
dimActive?: boolean
dimmedKeys?: string[]
/** 层折叠(仅隐藏层体,保留层头) */
collapsed?: boolean
getNodeLevel: (key: string) => string getNodeLevel: (key: string) => string
getNodeProgress: (key: string) => any getNodeProgress: (key: string) => any
}>() }>()
@@ -116,6 +133,8 @@ const emit = defineEmits<{
(e: 'update:label', val: string): void (e: 'update:label', val: string): void
(e: 'update:color', val: string): void (e: 'update:color', val: string): void
(e: 'update:icon', val: string): void (e: 'update:icon', val: string): void
(e: 'toggle-collapse'): void
(e: 'reorder-start'): void
(e: 'edit-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void (e: 'edit-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void
(e: 'delete-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void (e: 'delete-kr', payload: { dimKey: string; idx: number; obj: any; ki: number; kr: any }): void
(e: 'add-kr', payload: { dimKey: string; idx: number; obj: any }): void (e: 'add-kr', payload: { dimKey: string; idx: number; obj: any }): void
@@ -136,6 +155,17 @@ function onLayerAction(cmd: string) {
emit('layer-action', cmd) emit('layer-action', cmd)
} }
/** 搜索/筛选/因果链三种变淡来源合并(任一命中即变淡) */
function isDimmed(key: string): boolean {
if (props.chainNodeKeys && props.chainNodeKeys.length > 0 && !props.chainNodeKeys.includes(key)) return true
if (props.dimActive && !(props.dimmedKeys || []).includes(key)) return true
return false
}
function onDragStart() {
emit('reorder-start')
}
/** /**
* 拖拽排序完成回调(由 vuedraggable/SortableJS 触发) * 拖拽排序完成回调(由 vuedraggable/SortableJS 触发)
* 由于 :list 传递的是父级响应式数组的引用,SortableJS 已直接完成了数组重排, * 由于 :list 传递的是父级响应式数组的引用,SortableJS 已直接完成了数组重排,
@@ -164,7 +194,7 @@ export default {
border-radius: 12px; border-radius: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #fff; background: var(--bh-bg-card);
min-height: 120px; min-height: 120px;
width: 100%; width: 100%;
box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
@@ -193,6 +223,23 @@ export default {
line-height: 1; line-height: 1;
} }
.layer-collapse-btn {
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
flex-shrink: 0;
}
.layer-collapse-btn:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
.layer-collapse-btn svg { width: 14px; height: 14px; }
.layer-name { .layer-name {
flex: 1; flex: 1;
} }
+579 -65
View File
@@ -25,6 +25,35 @@
@toggle-chain-mode="toggleChainMode" @toggle-chain-mode="toggleChainMode"
/> />
<MapSummaryBar
:revenue="summaryData ? summaryData.revenue : null"
:gross-margin="summaryData ? summaryData.gross_margin : null"
:management="summaryData && summaryData.management ? summaryData.management : []"
:switch-info="summaryData ? summaryData.switch : null"
:achievement="overallAchievement"
:achievement-count="achievementCount"
:summary="reviewSummary"
:loading="loading"
/>
<MapEditBar
:search="searchText"
:filter="filterMode"
:visible-layers="visibleLayers"
:layer-configs="layerUiConfigs"
:can-undo="undoStack.length > 0"
:tree-open="treeOpen"
@update:search="v => searchText = v"
@update:filter="v => filterMode = v"
@toggle-layer="toggleLayer"
@add-objective="openAddDialog"
@connect="showConnManager = true"
@undo="undoLast"
@fit="zoomFit"
@toggle-tree="treeOpen = !treeOpen"
@completeness="showCompleteness = true"
/>
<!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 --> <!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 -->
<div v-if="caliberFacts" class="caliber-strip"> <div v-if="caliberFacts" class="caliber-strip">
<CaliberBadge kind="account_invoice_gap" :facts="caliberFacts" /> <CaliberBadge kind="account_invoice_gap" :facts="caliberFacts" />
@@ -35,49 +64,72 @@
<!-- 因果链查看模式提示 --> <!-- 因果链查看模式提示 -->
<div v-if="chainMode" class="chain-mode-bar"> <div v-if="chainMode" class="chain-mode-bar">
<span>🔍 因果链查看模式 — 点击节点聚焦其相关因果链(再次点击取消),其余节点自动变淡</span> <span class="chain-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="11" cy="11" r="6" /><path d="M20 20l-4.2-4.2" /></svg>
因果链查看模式 — 点击节点聚焦其相关因果链(再次点击取消),其余节点自动变淡
</span>
<el-button size="small" type="warning" @click="toggleChainMode">退出模式</el-button> <el-button size="small" type="warning" @click="toggleChainMode">退出模式</el-button>
</div> </div>
<!-- 连线模式提示 --> <!-- 连线模式提示 -->
<div v-if="linkingFrom" class="linking-bar"> <div v-if="linkingFrom" class="linking-bar">
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span> <span class="chain-ico">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1" /><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></svg>
从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线
</span>
<el-button size="small" @click="cancelLink">取消</el-button> <el-button size="small" @click="cancelLink">取消</el-button>
</div> </div>
<!-- 空状态提示 --> <!-- 仪表盘主视图:左目标树 / 中画布 / 右详情 -->
<div v-if="!loading && dimensions.every(d => d.objectives.length === 0)" class="empty-state"> <div class="map-body-row">
<el-empty description="四层泳道已就绪,点击各层的「+ 添加目标」开始填写"> <MapTargetTree
<template #image> v-show="treeOpen"
<svg viewBox="0 0 120 120" width="120" height="120"> :groups="treeGroups"
<rect x="10" y="10" width="100" height="22" rx="4" fill="#FEF0F0" /> :search="searchText"
<rect x="10" y="38" width="100" height="22" rx="4" fill="#ECF5FF" /> :filter="filterMode"
<rect x="10" y="66" width="100" height="22" rx="4" fill="#F0F9EB" /> :active-key="selectedKey"
<rect x="10" y="94" width="100" height="22" rx="4" fill="#FDF6EC" /> :collapsed="collapsedGroups"
</svg> :visible-count="visibleObjectiveCount"
</template> :total-count="totalObjectiveCount"
</el-empty> @select="onTreeNodeSelect"
</div> @toggle-group="k => collapsedGroups[k] = !collapsedGroups[k]"
@add-objective="openAddDialog"
@drag-start="onTreeDragStart"
@drag-end="onTreeDragEnd"
/>
<div class="map-canvas-col">
<!-- 画布主体(田字格2×2布局,transform: scale 缩放) --> <!-- 画布主体(田字格2×2布局,transform: scale 缩放) -->
<div class="canvas-scroll-wrap" ref="scrollWrapRef"> <div
class="canvas-scroll-wrap"
ref="scrollWrapRef"
@dragover.prevent="onTreeDragOver"
@dragleave="onTreeDragLeave"
@drop.prevent="onTreeDrop"
>
<div class="canvas-zoom-wrap" :style="{ width: zoomWrapWidth, height: zoomWrapHeight }"> <div class="canvas-zoom-wrap" :style="{ width: zoomWrapWidth, height: zoomWrapHeight }">
<div class="canvas-body" :class="{ 'grid-mode': gridMode }" ref="bodyRef" :style="bodyZoomStyle"> <div class="canvas-body" :class="{ 'grid-mode': gridMode }" ref="bodyRef" :style="bodyZoomStyle">
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) --> <!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
<div <div
v-for="(cfg, idx) in orderedLayerConfigs" v-for="(cfg, idx) in orderedLayerConfigs"
:key="cfg.key" :key="cfg.key"
v-show="visibleLayers[cfg.key]"
:ref="el => setLayerRef(cfg.key, el)" :ref="el => setLayerRef(cfg.key, el)"
class="layer-swimlane-wrapper" class="layer-swimlane-wrapper"
:style="{ borderLeft: '4px solid ' + cfg.color }" :style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }"
> >
<StrategyLayer <StrategyLayer
:ref="el => setLayerCompRef(cfg.key, el)" :ref="el => setLayerCompRef(cfg.key, el)"
:layer-key="cfg.key" :layer-key="cfg.key"
:label="cfg.label" :label="cfg.label"
:icon="cfg.icon" :icon="''"
:color="cfg.color" :color="layerColor(cfg.key)"
:objectives="getLayerNodes(cfg.key)" :objectives="getLayerNodes(cfg.key)"
:collapsed="!!collapsedLayers[cfg.key]"
:dim-active="filterActive"
:dimmed-keys="matchedKeyList"
@toggle-collapse="toggleLayerCollapse(cfg.key)"
@reorder-start="pushUndo"
:linking-from-key="linkingFrom?.key ?? null" :linking-from-key="linkingFrom?.key ?? null"
:drag-connect-target="dragConnectTarget" :drag-connect-target="dragConnectTarget"
:link-eligibility-map="linkEligibilityMap" :link-eligibility-map="linkEligibilityMap"
@@ -114,6 +166,44 @@
</div><!-- end canvas-zoom-wrap --> </div><!-- end canvas-zoom-wrap -->
</div><!-- end canvas-scroll-wrap --> </div><!-- end canvas-scroll-wrap -->
<!-- 常驻图例(语义色与阈值:绿≥90% / 黄60-90% / 红<60%) -->
<div class="map-legend" aria-label="达成率图例">
<span class="lg-item"><i class="lg-dot lv-green"></i>达标 ≥90%</span>
<span class="lg-item"><i class="lg-dot lv-yellow"></i>关注 60–90%</span>
<span class="lg-item"><i class="lg-dot lv-red"></i>风险 &lt;60%</span>
<span class="lg-item"><i class="lg-dot lv-gray"></i>无数据</span>
<span class="lg-note">达成率按指标方向归一,取目标关联 KPI 均值</span>
</div>
</div><!-- end map-canvas-col -->
<MapNodeDetail
v-if="selectedNode"
:obj="selectedNode.obj"
:dim-key="selectedNode.dimKey"
:index="selectedNode.index"
:layer-label="layerLabel(selectedNode.dimKey)"
:layer-color="layerColor(selectedNode.dimKey)"
:progress="getNodeProgress(selectedKey || '')"
:connections="connections"
:node-label-map="nodeLabelMap"
:trend-map="trendMap"
:trend-loading="trendLoading"
@close="closeDetail"
@edit="openEditDialog(selectedNode.dimKey, selectedNode.index, selectedNode.obj)"
@open-kpi-detail="showKpiDetailModal(selectedNode.dimKey, selectedNode.index, selectedNode.obj)"
@open-kpi="goKPI"
@open-plan="showPlanList(selectedNode.dimKey, selectedNode.index, selectedNode.obj)"
@select-key="selectByKey"
@go-kpi-library="goKpiLibrary"
/>
<aside v-else class="map-detail-empty" aria-label="目标详情占位">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 9h10M7 13h7M7 17h4" />
</svg>
<p>选中左侧目标或画布节点<br />查看达成率、KPI 与因果上下游</p>
</aside>
</div><!-- end map-body-row -->
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) --> <!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
<ConnectionLines <ConnectionLines
ref="connectionLinesRef" ref="connectionLinesRef"
@@ -177,7 +267,7 @@
@update-connection="onManagerUpdateConnection" @update-connection="onManagerUpdateConnection"
/> />
<!-- KR 删除二次确认(2026-09-16):项目统一弹窗组件 MyDialog(el-dialog 有历史 bug) --> <!-- KR 删除二次确认(2026-09-16):项目统一弹窗组件 MyDialog(原生 dialog 组件有历史 bug) -->
<MyDialog v-model="showKrDeleteConfirm" title="删除关键结果" :width="420"> <MyDialog v-model="showKrDeleteConfirm" title="删除关键结果" :width="420">
<div class="kr-del-confirm-text"> <div class="kr-del-confirm-text">
确定删除关键结果「<strong>{{ krDeleteTarget?.name }}</strong>」? 确定删除关键结果「<strong>{{ krDeleteTarget?.name }}</strong>」?
@@ -190,6 +280,21 @@
<el-button size="small" type="danger" :loading="krDeleting" @click="confirmDeleteKr">删除</el-button> <el-button size="small" type="danger" :loading="krDeleting" @click="confirmDeleteKr">删除</el-button>
</template> </template>
</MyDialog> </MyDialog>
<!-- 数据完备度与清单(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0) -->
<MyDialog v-model="showCompleteness" title="数据完备度 · 战略地图" :width="880">
<MapCompleteness v-if="showCompleteness" />
</MyDialog>
<!-- 版本回滚二次确认(MyDialog;不使用系统确认框) -->
<MyDialog v-model="showRollbackConfirm" title="版本回滚" :width="440">
<div class="kr-del-confirm-text">确定回滚到此版本?当前内容将被覆盖。</div>
<div class="kr-del-confirm-hint">回滚会以所选版本覆盖当前画布内容,执行后页面自动刷新。</div>
<template #footer>
<el-button size="small" @click="showRollbackConfirm = false">取消</el-button>
<el-button size="small" type="danger" :loading="rollbacking" @click="confirmRollback">回滚</el-button>
</template>
</MyDialog>
</div> </div>
<!-- P1-3: 关联知识点 --> <!-- P1-3: 关联知识点 -->
<div style="margin-top:12px;"> <div style="margin-top:12px;">
@@ -200,7 +305,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue" import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
import { useRoute } from "vue-router" import { useRoute } from "vue-router"
import { ElMessage, ElMessageBox } from "element-plus" import { ElMessage } from "element-plus"
import { mapApi, kpiApi, okrTemplateApi, okrApi } from "../api/index" import { mapApi, kpiApi, okrTemplateApi, okrApi } from "../api/index"
import KnowledgePanel from '../components/KnowledgePanel.vue' import KnowledgePanel from '../components/KnowledgePanel.vue'
import api from "../api/index" import api from "../api/index"
@@ -208,6 +313,11 @@ import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
import { useFormGuard } from "../composables/useFormGuard" import { useFormGuard } from "../composables/useFormGuard"
import { sanitizeKrName } from "../utils/krName" import { sanitizeKrName } from "../utils/krName"
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue" import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
import MapSummaryBar from "../components/strategy-map/MapSummaryBar.vue"
import MapEditBar from "../components/strategy-map/MapEditBar.vue"
import MapTargetTree from "../components/strategy-map/MapTargetTree.vue"
import MapNodeDetail from "../components/strategy-map/MapNodeDetail.vue"
import MapCompleteness from "../components/strategy-map/MapCompleteness.vue"
import ConnectionLines from "../components/strategy/ConnectionLines.vue" import ConnectionLines from "../components/strategy/ConnectionLines.vue"
import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue" import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue" import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue"
@@ -251,6 +361,46 @@ const loading = ref(true)
// 口径标识:仅登记过缺票事实的账套(当前为 entity 2 陕西博海)才挂「科目口径 · 缺票」 // 口径标识:仅登记过缺票事实的账套(当前为 entity 2 陕西博海)才挂「科目口径 · 缺票」
const caliberFacts = ref(caliberGapFor(currentCaliberEntityId())) const caliberFacts = ref(caliberGapFor(currentCaliberEntityId()))
// ── 仪表盘视图状态(2026-10-18 V1+V3 合版) ──
/** 四层视觉色(本页按设计规范呈现,不改动地图存储的颜色值) */
const LAYER_VISUAL: Record<string, { short: string; color: string }> = {
finance: { short: 'L1', color: '#0F172A' },
customer: { short: 'L2', color: '#635BFF' },
process: { short: 'L3', color: '#0E9F6E' },
learning: { short: 'L4', color: '#F59E0B' },
}
const layerUiConfigs = computed(() => orderedLayerConfigs.map((c: any) => ({
key: c.key,
label: c.label,
short: LAYER_VISUAL[c.key]?.short || c.key,
color: LAYER_VISUAL[c.key]?.color || c.color,
})))
function layerColor(key: string): string {
return LAYER_VISUAL[key]?.color || LAYER_CONFIG[key]?.color || '#635BFF'
}
function layerLabel(key: string): string {
return dimensions.find((d: any) => d.key === key)?.label || LAYER_CONFIG[key]?.label || key
}
const searchText = ref('')
const filterMode = ref<'all' | 'risk' | 'nodata'>('all')
const visibleLayers = reactive<Record<string, boolean>>({ finance: true, customer: true, process: true, learning: true })
const collapsedLayers = reactive<Record<string, boolean>>({})
const collapsedGroups = reactive<Record<string, boolean>>({})
const treeOpen = ref(true)
const selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(null)
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : null))
const reviewSummary = ref<any>(null)
const summaryData = ref<any>(null)
const trendMap = reactive<Record<string, { dir: 'up' | 'down' | 'flat' | null; deltaPct: number | null; period?: string | null }>>({})
const trendLoading = ref(false)
// 撤销栈(仅本页内存,不落库;最多 20 步)
const undoStack = ref<{ dimensions: any; connections: any[] }[]>([])
// 连线 // 连线
const connections = ref<any[]>([]) const connections = ref<any[]>([])
const linkingFrom = ref<{ key: string; obj: any } | null>(null) const linkingFrom = ref<{ key: string; obj: any } | null>(null)
@@ -286,6 +436,7 @@ function onChainFocus(nodes: string[] | null) { chainFocusNodes.value = nodes }
/** 更新连线语义(effect/label),落库 */ /** 更新连线语义(effect/label),落库 */
function onUpdateConnection(idx: number, patch: any) { function onUpdateConnection(idx: number, patch: any) {
if (!connections.value[idx]) return if (!connections.value[idx]) return
pushUndo()
Object.assign(connections.value[idx], patch) Object.assign(connections.value[idx], patch)
markDirty() markDirty()
saveConnections() saveConnections()
@@ -334,14 +485,107 @@ function updateGridMode() {
// 节点状态(红黄绿灯) // 节点状态(红黄绿灯)
const objectiveLevels = reactive<Record<string, string>>({}) const objectiveLevels = reactive<Record<string, string>>({})
interface KpiListItem {
kpi_id?: number
code: string
name: string
actual: number | null
target: number | null
unit: string | null
level?: string
ratio?: number | null
}
interface KpiProgressItem { interface KpiProgressItem {
ratio: number /** 达成率百分比(0-…),无数据为 null */
ratio: number | null
level: string level: string
kpiList: { code: string; name: string; actual: number|null; target: number|null; unit: string|null }[] kpiList: KpiListItem[]
planSummary: { total: number; pending: number; in_progress: number; completed: number; overdue: number } | null planSummary: any | null
/** 有实测值/有实测值 KPI 数 */
hasData?: boolean
kpiTotal?: number
kpiWithData?: number
} }
const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({}) const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({})
/** 达成率均值(仅统计有实测值的目标);null = 无数据 */
const overallAchievement = computed<number | null>(() => {
const vals = Object.values(kpiProgressMap)
.filter((p: any) => p.hasData && p.ratio != null)
.map((p: any) => Number(p.ratio))
if (vals.length === 0) return null
return vals.reduce((a, b) => a + b, 0) / vals.length
})
const achievementCount = computed(() =>
Object.values(kpiProgressMap).filter((p: any) => p.hasData && p.ratio != null).length
)
/** 搜索/筛选是否激活(激活时画布非匹配节点变淡) */
const filterActive = computed(() => searchText.value.trim() !== '' || filterMode.value !== 'all')
/** 全部节点 key → 名称(因果上下游展示用) */
const nodeLabelMap = computed<Record<string, string>>(() => {
const map: Record<string, string> = {}
for (const dim of dimensions) {
(dim.objectives || []).forEach((o: any, i: number) => { map[`${dim.key}-${i}`] = o.name || `目标 ${i + 1}` })
}
return map
})
function objMatches(o: any, p: any, kw: string): boolean {
if (!kw) return true
const hay = [
o?.name, o?.owner, o?.description,
...(p?.kpiList || []).map((k: any) => k.name),
...(o?.krs || []).map((k: any) => k.name),
].filter(Boolean).join(' ').toLowerCase()
return hay.includes(kw)
}
/** 左侧目标树:四层分组 + 搜索 + 只看风险/无数据 */
const treeGroups = computed(() => {
const kw = searchText.value.trim().toLowerCase()
return orderedLayerConfigs
.filter((cfg: any) => visibleLayers[cfg.key])
.map((cfg: any) => {
const objectives = (getLayerNodes(cfg.key) || []).map((o: any, i: number) => {
const key = `${cfg.key}-${i}`
const p = kpiProgressMap[key]
return {
key,
index: i,
dimKey: cfg.key,
name: o.name || `未命名目标 ${i + 1}`,
owner: o.owner || '',
level: p?.level || 'gray',
ratio: p?.ratio ?? null,
hasData: !!p?.hasData,
kpiCount: (o.kpis?.length || 0) || (p?.kpiList?.length || 0),
krs: o.krs || [],
raw: o,
}
}).filter((o: any) => {
if (!objMatches(o.raw, kpiProgressMap[o.key], kw)) return false
if (filterMode.value === 'risk') return o.level === 'red' || o.level === 'yellow'
if (filterMode.value === 'nodata') return !o.hasData
return true
})
return { key: cfg.key, label: cfg.label, color: layerColor(cfg.key), objectives }
})
})
/** 当前筛选命中节点(画布变淡判据) */
const matchedKeyList = computed<string[]>(() =>
treeGroups.value.flatMap((g: any) => g.objectives.map((o: any) => o.key))
)
const totalObjectiveCount = computed(() =>
dimensions.reduce((n: number, d: any) => n + (d.objectives?.length || 0), 0)
)
const visibleObjectiveCount = computed(() =>
treeGroups.value.reduce((n: number, g: any) => n + g.objectives.length, 0)
)
// ── A3 维度切片(时间/门店/产品)────────────────────────────── // ── A3 维度切片(时间/门店/产品)──────────────────────────────
// 切片激活时用它覆盖 objectiveLevels / kpiProgressMap 的展示值: // 切片激活时用它覆盖 objectiveLevels / kpiProgressMap 的展示值:
// - available=false 的 KPI 一律 null,不用总额或其它期间冒充 // - available=false 的 KPI 一律 null,不用总额或其它期间冒充
@@ -408,6 +652,12 @@ function onSaveDialog(data: any) {
const showVersions = ref(false) const showVersions = ref(false)
const versions = ref<any[]>([]) const versions = ref<any[]>([])
// 数据完备度弹窗 + 版本回滚确认(MyDialog,替代系统确认框)
const showCompleteness = ref(false)
const showRollbackConfirm = ref(false)
const rollbacking = ref(false)
const pendingRollbackVerId = ref<number | null>(null)
const showCausalityDialog = ref(false) const showCausalityDialog = ref(false)
const causalityRecommendations = ref<any[]>([]) const causalityRecommendations = ref<any[]>([])
const causalityLoading = ref(false) const causalityLoading = ref(false)
@@ -559,6 +809,8 @@ function loadCanvas() {
chainFocusKey.value = null chainFocusKey.value = null
chainHoverNodes.value = null chainHoverNodes.value = null
chainFocusNodes.value = null chainFocusNodes.value = null
selectedNode.value = null
reviewSummary.value = null
loading.value = true loading.value = true
api.get("/maps").then((r: any) => { api.get("/maps").then((r: any) => {
@@ -640,14 +892,157 @@ function onNodeClick(dimKey: string, oi: number, obj: any) {
chainFocusKey.value = chainFocusKey.value === key ? null : key chainFocusKey.value = chainFocusKey.value === key ? null : key
return return
} }
// 有KPI则展示详情 // 常规:选中节点 → 右侧详情面板(2026-10-18)
if (kpiProgressMap[key]?.kpiList?.length > 0) { selectNode(dimKey, oi, obj)
showKpiDetailModal(dimKey, oi, obj) }
} else {
openEditDialog(dimKey, oi, obj) /** 选中目标并在右侧面板展示详情(真数据来自 /review + /kpis/{id}/values) */
function selectNode(dimKey: string, oi: number, obj: any) {
selectedNode.value = { dimKey, index: oi, obj }
loadTrends(obj)
}
function onTreeNodeSelect(o: any) {
const cur = dimensions.find((d: any) => d.key === o.dimKey)?.objectives?.[o.index]
if (cur) selectNode(o.dimKey, o.index, cur)
}
/** 从详情面板的因果 chip 跳到对应节点 */
function selectByKey(key: string) {
const [dimKey, idxRaw] = key.split('-')
const idx = Number(idxRaw)
const obj = dimensions.find((d: any) => d.key === dimKey)?.objectives?.[idx]
if (obj) selectNode(dimKey, idx, obj)
}
function closeDetail() {
selectedNode.value = null
}
/** KPI 趋势:取每支 KPI 最近两期实测值(接口真实数据;不足两期=无趋势) */
async function loadTrends(obj: any) {
for (const key of Object.keys(trendMap)) delete trendMap[key]
if (!obj) return
const progress = kpiProgressMap[selectedKey.value || '']
const list = (progress?.kpiList || []).filter((k: any) => k.kpi_id)
if (list.length === 0) return
trendLoading.value = true
try {
await Promise.all(list.map(async (k: any) => {
try {
const r: any = await api.get(`/kpis/${k.kpi_id}/values`)
const vals = (r?.data || [])
.filter((v: any) => v.actual_value != null)
.sort((a: any, b: any) => String(b.period).localeCompare(String(a.period)))
if (vals.length === 0) return
const latest = Number(vals[0].actual_value)
if (vals.length < 2) {
trendMap[k.code] = { dir: null, deltaPct: null, period: vals[0].period }
return
}
const prev = Number(vals[1].actual_value)
const delta = latest - prev
const deltaPct = prev !== 0 ? (delta / Math.abs(prev)) * 100 : null
trendMap[k.code] = {
dir: Math.abs(delta) < 1e-9 ? 'flat' : delta > 0 ? 'up' : 'down',
deltaPct,
period: vals[0].period,
}
} catch { /* 单支 KPI 取数失败不影响其它 */ }
}))
} finally {
trendLoading.value = false
} }
} }
// ── 层筛选 / 层折叠 ──
function toggleLayer(key: string) {
visibleLayers[key] = !visibleLayers[key]
nextTick(() => { updateGridMode(); triggerRecalc(); updateZoomWrapHeight() })
}
function toggleLayerCollapse(key: string) {
collapsedLayers[key] = !collapsedLayers[key]
nextTick(() => { triggerRecalc(); updateZoomWrapHeight() })
}
// ── 撤销(本页内存栈) ──
function pushUndo() {
undoStack.value.push({
dimensions: JSON.parse(JSON.stringify(dimensions.map((d: any) => ({
key: d.key, label: d.label, name: d.label || d.name, icon: d.icon, color: d.color, objectives: d.objectives,
})))),
connections: JSON.parse(JSON.stringify(connections.value)),
})
if (undoStack.value.length > 20) undoStack.value.shift()
}
function undoLast() {
const snap = undoStack.value.pop()
if (!snap) return
dimensions.splice(0, dimensions.length, ...JSON.parse(JSON.stringify(snap.dimensions)))
connections.value = JSON.parse(JSON.stringify(snap.connections))
selectedNode.value = null
markDirty()
nextTick(() => { triggerRecalc(); updateZoomWrapHeight(); loadObjectiveLevels() })
ElMessage.success('已撤销上一步')
}
// ── 目标树 → 画布拖拽建因果连线(复用 completeConnection / dragConnectTarget) ──
const treeDragSource = ref<{ key: string; obj: any } | null>(null)
function onTreeDragStart(payload: { dimKey: string; index: number; obj: any }) {
treeDragSource.value = { key: `${payload.dimKey}-${payload.index}`, obj: payload.obj }
startLink(payload.dimKey, payload.index, payload.obj)
}
function onTreeDragOver(e: DragEvent) {
if (!treeDragSource.value) return
const key = hitTestTreeNodeAt(e.clientX, e.clientY)
dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null
}
/**
* D3(2026-10-20):窄视口下 .kp-panel 是 fixed + z-index:1000 的浮层,会盖住画布主体;
* 旧实现用 document.elementFromPoint() 取节点 —— 命中面板时 .closest('[data-node-key]') 恒 null,
* 拖拽建连线必走 cancelLink()。改为**几何命中**:直接遍历画布内全部 [data-node-key],
* 用 getBoundingClientRect() 做包含判断(rect 已含 transform: scale,与 clientX/Y 同坐标系);
* 多点重叠时取中心距拖拽点最近者。**不再回退 elementFromPoint**(否则窄屏复发)。
* 性能:19 个节点的 rect 遍历一帧一次 ≈ 微秒级,无需 rAF/节流,避免节流造成 drop 时刻判据滞后。
*/
function hitTestTreeNodeAt(x: number, y: number): string | null {
const box = scrollWrapRef.value
if (!box) return null
const nodes = box.querySelectorAll<HTMLElement>('[data-node-key]')
let bestKey: string | null = null
let bestDist = Number.POSITIVE_INFINITY
for (let i = 0; i < nodes.length; i++) {
const r = nodes[i].getBoundingClientRect()
if (r.width === 0 && r.height === 0) continue // 隐藏层/折叠层
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue
const dx = x - (r.left + r.width / 2)
const dy = y - (r.top + r.height / 2)
const d = dx * dx + dy * dy
if (d < bestDist) { bestDist = d; bestKey = nodes[i].getAttribute('data-node-key') }
}
return bestKey
}
function onTreeDragLeave(e: DragEvent) {
const related = (e as any).relatedTarget as Node | null
const box = scrollWrapRef.value
if (box && related && box.contains(related)) return
dragConnectTarget.value = null
}
function onTreeDrop() {
const target = dragConnectTarget.value
const src = treeDragSource.value
dragConnectTarget.value = null
treeDragSource.value = null
if (src && target && target !== src.key) completeConnection(src.key, target)
else cancelLink()
}
function onTreeDragEnd() {
treeDragSource.value = null
dragConnectTarget.value = null
cancelLink()
}
/** /**
* 层内重排完成回调 — 由 vuedraggable 在拖拽排序后触发 * 层内重排完成回调 — 由 vuedraggable 在拖拽排序后触发
* SortableJS 已通过 :list 引用直接更新了响应式数组, * SortableJS 已通过 :list 引用直接更新了响应式数组,
@@ -665,6 +1060,7 @@ function completeConnection(fromKey: string, toKey: string) {
if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) { if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) {
ElMessage.warning("已存在相同连线"); cancelLink(); return ElMessage.warning("已存在相同连线"); cancelLink(); return
} }
pushUndo()
api.post(`/maps/${selectedMap.value}/connections`, { api.post(`/maps/${selectedMap.value}/connections`, {
from: fromKey, to: toKey, from: fromKey, to: toKey,
}).then((r: any) => { }).then((r: any) => {
@@ -833,6 +1229,7 @@ async function onManagerAddConnection(payload: { from: string; to: string; effec
function onManagerDeleteConnection(idx: number) { function onManagerDeleteConnection(idx: number) {
const conn = connections.value[idx] const conn = connections.value[idx]
if (!conn) return if (!conn) return
pushUndo()
api.delete(`/maps/${selectedMap.value}/connections`, { data: { from: conn.from, to: conn.to } }) api.delete(`/maps/${selectedMap.value}/connections`, { data: { from: conn.from, to: conn.to } })
.then((r: any) => { .then((r: any) => {
if (r?.updated_at) baseUpdatedAt.value = r.updated_at if (r?.updated_at) baseUpdatedAt.value = r.updated_at
@@ -855,6 +1252,7 @@ function onDeleteConnection(idx: number) {
if (selectedConnIdx.value !== idx) return if (selectedConnIdx.value !== idx) return
const conn = connections.value[idx] const conn = connections.value[idx]
if (!conn) return if (!conn) return
pushUndo()
api.delete(`/maps/${selectedMap.value}/connections`, { api.delete(`/maps/${selectedMap.value}/connections`, {
data: { from: conn.from, to: conn.to }, data: { from: conn.from, to: conn.to },
}).then((r: any) => { }).then((r: any) => {
@@ -872,7 +1270,8 @@ function onDeleteConnection(idx: number) {
// ── 节点CRUD ── // ── 节点CRUD ──
function openAddDialog(layerKey: string) { function openAddDialog(layerKey: string) {
editingLayerKey.value = layerKey const key = layerKey && LAYER_CONFIG[layerKey] ? layerKey : 'finance'
editingLayerKey.value = key
editingNodeData.value = null editingNodeData.value = null
dialogForm.name = '' dialogForm.name = ''
dialogForm.description = '' dialogForm.description = ''
@@ -911,6 +1310,7 @@ function openEditDialog(dimKey: string, oi: number, obj: any) {
} }
function onNodeSave(data: any) { function onNodeSave(data: any) {
pushUndo()
const layer = editingLayerKey.value || data.layer || data.dimKey const layer = editingLayerKey.value || data.layer || data.dimKey
const dim = dimensions.find((d: any) => d.key === layer) const dim = dimensions.find((d: any) => d.key === layer)
if (!dim) return if (!dim) return
@@ -1042,6 +1442,7 @@ async function deleteNode(dimKey: string, oi: number) {
const dim = dimensions.find((d: any) => d.key === dimKey) const dim = dimensions.find((d: any) => d.key === dimKey)
if (dim) { if (dim) {
const key = `${dimKey}-${oi}` const key = `${dimKey}-${oi}`
pushUndo()
connections.value = connections.value.filter(c => c.from !== key && c.to !== key) connections.value = connections.value.filter(c => c.from !== key && c.to !== key)
dim.objectives.splice(oi, 1) dim.objectives.splice(oi, 1)
ElMessage.success("节点已删除") ElMessage.success("节点已删除")
@@ -1065,6 +1466,7 @@ function onAddKr(dimKey: string, oi: number, obj: any) {
ElMessage.warning('每个目标最多5个关键结果') ElMessage.warning('每个目标最多5个关键结果')
return return
} }
pushUndo()
target.krs.push({ target.krs.push({
name: "", name: "",
target_value: "", target_value: "",
@@ -1118,6 +1520,7 @@ async function confirmDeleteKr() {
const krId: number | null = kr._kr_id || kr.id || null const krId: number | null = kr._kr_id || kr.id || null
const removedName = kr.name || `KR ${t.ki + 1}` const removedName = kr.name || `KR ${t.ki + 1}`
// 1) 本地移除(界面即时无残留) // 1) 本地移除(界面即时无残留)
pushUndo()
target.krs.splice(t.ki, 1) target.krs.splice(t.ki, 1)
cancelDeleteKr() cancelDeleteKr()
try { try {
@@ -1384,13 +1787,22 @@ async function loadVersions() {
} }
async function rollback(verId: number) { async function rollback(verId: number) {
pendingRollbackVerId.value = verId
showRollbackConfirm.value = true
}
async function confirmRollback() {
if (pendingRollbackVerId.value == null) return
rollbacking.value = true
try { try {
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。") await api.post(`/maps/${selectedMap.value}/versions/${pendingRollbackVerId.value}/rollback`)
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
ElMessage.success("回滚成功,刷新页面") ElMessage.success("回滚成功,刷新页面")
showRollbackConfirm.value = false
window.location.reload() window.location.reload()
} catch (e: any) { } catch (e: any) {
if (e !== "cancel") ElMessage.error("回滚失败") ElMessage.error("回滚失败")
} finally {
rollbacking.value = false
} }
} }
@@ -1399,46 +1811,54 @@ async function loadObjectiveLevels() {
if (!selectedMap.value) return if (!selectedMap.value) return
try { try {
const r: any = await api.get(`/maps/${selectedMap.value}/review?level=strategic`) const r: any = await api.get(`/maps/${selectedMap.value}/review?level=strategic`)
reviewSummary.value = r.summary || null
for (const key of Object.keys(objectiveLevels)) delete objectiveLevels[key] for (const key of Object.keys(objectiveLevels)) delete objectiveLevels[key]
for (const key of Object.keys(kpiProgressMap)) delete kpiProgressMap[key] for (const key of Object.keys(kpiProgressMap)) delete kpiProgressMap[key]
for (const key of Object.keys(trendMap)) delete trendMap[key]
const dims = r.dimensions || [] const dims = r.dimensions || []
for (const dim of dims) { for (const dim of dims) {
const dimKey = dim.key const dimKey = dim.key
const localDim = dimensions.find((d: any) => d.key === dimKey) const localDim = dimensions.find((d: any) => d.key === dimKey)
if (!localDim) continue if (!localDim) continue
for (let i = 0; i < localDim.objectives.length; i++) { const reviewObjs = dim.objectives || []
const count = Math.max(localDim.objectives.length, reviewObjs.length)
for (let i = 0; i < count; i++) {
const key = `${dimKey}-${i}` const key = `${dimKey}-${i}`
kpiProgressMap[key] = { ratio: 0, level: 'gray', kpiList: [], planSummary: null } const localObj = localDim.objectives[i]
objectiveLevels[key] = 'gray' let robj = reviewObjs[i]
} // 后端按目标顺序返回(含无 KPI 目标)⇒ 索引对齐;名称不一致时按名称兜底
dim.objectives.forEach((obj: any) => { if (robj && localObj && robj.name !== localObj.name) {
const localIdx = localDim.objectives.findIndex((o: any) => o.name === obj.name) robj = reviewObjs.find((o: any) => o.name === localObj.name) || robj
if (localIdx >= 0) {
const key = `${dimKey}-${localIdx}`
objectiveLevels[key] = obj.level || 'gray'
const kpis = obj.kpis || []
const kpiList: any[] = []
let totalRatio = 0
let count = 0
for (const k of kpis) {
kpiList.push({
code: k.kpi_code, name: k.kpi_name, actual: k.actual_value, target: k.target_value, unit: k.unit,
})
if (k.actual_value != null && k.target_value) {
totalRatio += k.actual_value / k.target_value; count++
}
}
if (kpiList.length > 0) {
kpiProgressMap[key] = {
ratio: count > 0 ? Math.round((totalRatio / count) * 100) : 0,
level: obj.level || 'gray', kpiList,
planSummary: obj.action_plan_summary || null,
}
} else if (obj.action_plan_summary?.total > 0) {
kpiProgressMap[key] = { ratio: 0, level: obj.level || 'gray', kpiList: [], planSummary: obj.action_plan_summary }
}
} }
}) if (!robj) continue
objectiveLevels[key] = robj.level || 'gray'
const kpis: KpiListItem[] = (robj.kpis || []).map((k: any) => ({
kpi_id: k.kpi_id,
code: k.kpi_code,
name: k.kpi_name || k.kpi_code,
actual: k.actual_value ?? null,
target: k.target_value ?? null,
unit: k.unit ?? null,
level: k.level,
ratio: k.ratio != null ? Number(k.ratio) * 100 : null,
}))
const hasData = robj.has_actual === true && robj.ratio != null
kpiProgressMap[key] = {
ratio: hasData ? Math.round(Number(robj.ratio) * 100) : null,
level: robj.level || 'gray',
kpiList: kpis,
planSummary: robj.action_plan_summary || null,
hasData,
kpiTotal: robj.kpi_total != null ? robj.kpi_total : kpis.length,
kpiWithData: robj.kpi_with_data != null ? robj.kpi_with_data : kpis.filter(k => k.ratio != null).length,
}
}
}
// 选中节点仍有效则刷新引用(撤销/保存后保持详情面板数据一致)
if (selectedNode.value) {
const cur = dimensions.find((d: any) => d.key === selectedNode.value!.dimKey)?.objectives?.[selectedNode.value!.index]
if (cur) selectedNode.value = { ...selectedNode.value, obj: cur }
else selectedNode.value = null
} }
} catch { /* silent */ } } catch { /* silent */ }
} }
@@ -1495,6 +1915,23 @@ function goAlignment() {
window.location.href = url window.location.href = url
} }
function goKpiLibrary() {
window.location.href = '/kpis'
}
/**
* KPI 摘要条(分业务线营收 / 新式毛利率 / 管理指标):来源 /maps/summary
* 口径 CMA-A1 终稿;缺失即 null(不造数)。
*/
async function loadFinanceSummary() {
try {
const r: any = await api.get('/maps/summary')
summaryData.value = r || null
} catch {
summaryData.value = null
}
}
/** 导出画布为PNG图片(transform缩放下截图不完整:临时回100%导出) */ /** 导出画布为PNG图片(transform缩放下截图不完整:临时回100%导出) */
async function exportImage() { async function exportImage() {
const el = document.querySelector('.canvas-scroll-wrap') as HTMLElement const el = document.querySelector('.canvas-scroll-wrap') as HTMLElement
@@ -1568,6 +2005,7 @@ async function applyAllCausality() {
// 滚动触发连线重算(rAF节流,避免滚动/拖拽时卡顿) // 滚动触发连线重算(rAF节流,避免滚动/拖拽时卡顿)
let scrollRaf = 0 let scrollRaf = 0
let resizeObserver: ResizeObserver | null = null
function onCanvasScroll() { function onCanvasScroll() {
if (scrollRaf) return if (scrollRaf) return
scrollRaf = requestAnimationFrame(() => { scrollRaf = requestAnimationFrame(() => {
@@ -1580,6 +2018,12 @@ function onWindowResize() {
updateZoomWrapHeight() updateZoomWrapHeight()
triggerRecalc() triggerRecalc()
} }
/** 账套切换(同浏览器跨标签页):entity 变化即整页重载,杜绝跨实体混数据 */
function onStorageEntityChange(e: StorageEvent) {
if (e.key === 'cma_entity_id' && e.newValue !== e.oldValue) {
window.location.reload()
}
}
onMounted(async () => { onMounted(async () => {
const r: any = await mapApi.list() const r: any = await mapApi.list()
@@ -1595,6 +2039,7 @@ onMounted(async () => {
ensureFourLayers() ensureFourLayers()
loading.value = false loading.value = false
} }
void loadFinanceSummary()
const k: any = await kpiApi.list({ page_size: 100 }) const k: any = await kpiApi.list({ page_size: 100 })
allKpis.value = k.data || [] allKpis.value = k.data || []
for (const kpi of allKpis.value) { for (const kpi of allKpis.value) {
@@ -1616,12 +2061,24 @@ onMounted(async () => {
}) })
scrollWrapRef.value?.addEventListener('scroll', onCanvasScroll, { passive: true }) scrollWrapRef.value?.addEventListener('scroll', onCanvasScroll, { passive: true })
window.addEventListener('resize', onWindowResize) window.addEventListener('resize', onWindowResize)
window.addEventListener('storage', onStorageEntityChange)
// 目标树/详情面板/层筛选改变画布宽度时重算连线(不依赖窗口 resize)
if (scrollWrapRef.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
updateZoomWrapHeight()
nextTick(() => connectionLinesRef.value?.recalcAll())
})
resizeObserver.observe(scrollWrapRef.value)
}
}) })
onUnmounted(() => { onUnmounted(() => {
disposed = true // 先置位:阻断仍在 await 中的 onMounted 续段再注册定时器(防泄漏) disposed = true // 先置位:阻断仍在 await 中的 onMounted 续段再注册定时器(防泄漏)
scrollWrapRef.value?.removeEventListener('scroll', onCanvasScroll) scrollWrapRef.value?.removeEventListener('scroll', onCanvasScroll)
window.removeEventListener('resize', onWindowResize) window.removeEventListener('resize', onWindowResize)
window.removeEventListener('storage', onStorageEntityChange)
resizeObserver?.disconnect()
resizeObserver = null
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
stopAutoSave() stopAutoSave()
}) })
@@ -1647,6 +2104,8 @@ onUnmounted(() => {
color: #b88230; color: #b88230;
} }
.chain-mode-bar span { flex: 1; } .chain-mode-bar span { flex: 1; }
.chain-ico { display: inline-flex; align-items: center; gap: 6px; }
.chain-ico svg { width: 14px; height: 14px; flex: 0 0 14px; }
.empty-state { .empty-state {
flex: 1; display: flex; align-items: center; justify-content: center; flex: 1; display: flex; align-items: center; justify-content: center;
@@ -1727,7 +2186,62 @@ onUnmounted(() => {
.kr-del-confirm-hint { .kr-del-confirm-hint {
margin-top: 8px; margin-top: 8px;
font-size: 12px; font-size: 12px;
color: #909399; color: var(--bh-text-secondary);
line-height: 1.5; line-height: 1.5;
} }
/* ── 仪表盘主视图三栏(2026-10-18 V1+V3 合版)── */
.map-body-row {
flex: 1;
min-height: 0;
display: flex;
gap: 10px;
align-items: stretch;
}
.map-canvas-col {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.map-detail-empty {
width: 320px;
flex: 0 0 320px;
border: 1px dashed #E5E7EB;
border-radius: 8px;
background: #FCFCFD;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #94A3B8;
padding: 20px;
text-align: center;
}
.map-detail-empty svg { width: 28px; height: 28px; color: #CBD5E1; }
.map-detail-empty p { margin: 0; font-size: 12px; line-height: 1.7; }
.map-legend {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 14px;
padding: 6px 2px 0;
font-size: 11px;
color: #64748B;
flex-wrap: wrap;
}
.lg-item { display: inline-flex; align-items: center; gap: 4px; }
.lg-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.lg-dot.lv-green { background: #00C896; }
.lg-dot.lv-yellow { background: #F59E0B; }
.lg-dot.lv-red { background: #EF4444; }
.lg-dot.lv-gray { background: #CBD5E1; }
.lg-note { margin-left: auto; color: #94A3B8; }
@media (max-width: 1280px) {
.map-detail-empty { display: none; }
}
</style> </style>