Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b736e0727a | ||
|
|
86e24c6310 | ||
|
|
ecbe55c48a | ||
|
|
c030fb7d18 | ||
|
|
53cad7eaa9 | ||
|
|
a8141e8879 | ||
|
|
4d20c27de4 |
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from typing import Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from app.database import get_db
|
||||
@@ -16,6 +17,15 @@ import logging
|
||||
|
||||
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=["战略地图"],
|
||||
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()
|
||||
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("")
|
||||
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
|
||||
@@ -538,6 +559,8 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit
|
||||
"label": data.get("label", ""),
|
||||
})
|
||||
m.canvas_data["connections"] = conns
|
||||
# D2(2026-10-20):裸 JSON 列不做就地变更跟踪;显式标脏,与模型层 MutableDict 双保险
|
||||
flag_modified(m, "canvas_data")
|
||||
db.commit()
|
||||
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
|
||||
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, "连线不存在")
|
||||
|
||||
m.canvas_data["connections"] = new_conns
|
||||
# D2(2026-10-20):换新列表赋值同样不被裸 JSON 列跟踪 ⇒ 显式标脏(根因修复点)
|
||||
flag_modified(m, "canvas_data")
|
||||
db.commit()
|
||||
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
|
||||
"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:
|
||||
continue
|
||||
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)
|
||||
target = kpi_def.target_monthly or kpi_def.target_value
|
||||
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%)
|
||||
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
|
||||
level = "gray"
|
||||
ratio_val = None
|
||||
if actual is not None and target:
|
||||
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
|
||||
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
|
||||
if ratio < 0:
|
||||
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
|
||||
ratio_val = ratio
|
||||
if ratio >= 0.9:
|
||||
level = "green"
|
||||
elif ratio >= 0.6:
|
||||
@@ -1051,6 +1080,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
else:
|
||||
level = "gray"
|
||||
|
||||
# C1 数据前置:held KPI 不着色、不透出达成率(level=pending 由前端灰虚线呈现)
|
||||
if held:
|
||||
level = "pending"
|
||||
ratio_val = None
|
||||
|
||||
if level == "red":
|
||||
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,
|
||||
"unit": kpi_def.unit,
|
||||
"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 = {
|
||||
"name": obj.get("name", ""),
|
||||
"icon": obj.get("icon", ""),
|
||||
"kpis": obj_kpis,
|
||||
"level": worst_level,
|
||||
"level": "pending" if hold_reason else worst_level,
|
||||
"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统计
|
||||
"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]]),
|
||||
@@ -1082,6 +1131,11 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
|
||||
}
|
||||
objectives.append(obj_item)
|
||||
|
||||
# C1 数据前置:数据待核目标单列,不进达标/关注/风险任何一档
|
||||
if hold_reason:
|
||||
total_hold += 1
|
||||
continue
|
||||
|
||||
if worst_level == "green":
|
||||
total_ok += 1
|
||||
elif worst_level == "yellow":
|
||||
|
||||
@@ -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": "学习成长",
|
||||
}
|
||||
@@ -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
@@ -7,7 +7,7 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from dotenv import load_dotenv
|
||||
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 scripts.erp_sync import run_sync as run_erp_sync
|
||||
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(templates.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(data.router)
|
||||
app.include_router(alerts.router)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""管理会计OS 数据模型"""
|
||||
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.models.budget_plan import BudgetPlan
|
||||
@@ -59,7 +60,7 @@ class StrategicMap(Base):
|
||||
version = Column(String(20), default="v1.0", comment="版本号")
|
||||
status = Column(String(20), default="draft", comment="draft/published")
|
||||
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_at = Column(DateTime, server_default=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)
|
||||
Vendored
+5
-1
@@ -15,7 +15,6 @@ declare module 'vue' {
|
||||
BotSourceTag: typeof import('./src/components/BotSourceTag.vue')['default']
|
||||
BudgetCollectPanel: typeof import('./src/components/BudgetCollectPanel.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']
|
||||
ConnectionLines: typeof import('./src/components/strategy/ConnectionLines.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']
|
||||
MapCanvasDialogs: typeof import('./src/components/map-canvas/MapCanvasDialogs.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']
|
||||
MapEditBar: typeof import('./src/components/strategy-map/MapEditBar.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']
|
||||
NodeEditDialog: typeof import('./src/components/strategy-map/NodeEditDialog.vue')['default']
|
||||
OkrDecomposition: typeof import('./src/components/okr/OkrDecomposition.vue')['default']
|
||||
|
||||
@@ -75,6 +75,9 @@ export const mapApi = {
|
||||
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 }),
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
<div
|
||||
class="map-node"
|
||||
:data-node-key="nodeKey"
|
||||
:class="{
|
||||
'linking-source': isLinkingSource,
|
||||
'linking-target': isLinkingTarget,
|
||||
@@ -118,7 +119,7 @@
|
||||
></div>
|
||||
</div>
|
||||
<span class="node-progress-text" :style="{ color: progressColor(progressData.level) }">
|
||||
{{ progressData.ratio > 0 ? progressData.ratio + '%' : '—' }}
|
||||
{{ progressData.hasData && progressData.ratio != null ? progressData.ratio + '%' : '无数据' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- KPI实际值快照:当前值 / 目标值 / 达成率 -->
|
||||
@@ -242,6 +243,8 @@ function getKpiName(code: string): string {
|
||||
}
|
||||
|
||||
function getKpiLevel(item: any): string {
|
||||
// 后端 /maps/{id}/review 已按方向(含越低越好)判级:优先使用,避免前端重复口径
|
||||
if (item.level) return item.level
|
||||
if (item.actual != null && item.target) {
|
||||
const ratio = item.actual / item.target
|
||||
if (ratio >= 0.9) return 'green'
|
||||
@@ -251,9 +254,10 @@ function getKpiLevel(item: any): string {
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
/** KPI达成率文本(绿≥90% / 黄60-90% / 红<60%) */
|
||||
/** KPI达成率文本(后端 ratio 已按指标方向计算;无数据显式标注) */
|
||||
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
|
||||
return Math.round(ratio * 100) + '%'
|
||||
}
|
||||
@@ -304,7 +308,7 @@ function getKrProgressText(kr: any): string {
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
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-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-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-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-yellow { background: #fdf6ec; }
|
||||
.snap-red { background: #fef0f0; }
|
||||
.snap-gray { background: #f5f5f5; }
|
||||
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 92px; color: #666; }
|
||||
.snap-gray { background: var(--bh-bg-main); }
|
||||
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 92px; color: var(--bh-text-secondary); }
|
||||
.snap-value {
|
||||
font-weight: 600; color: #333; white-space: nowrap;
|
||||
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-red { color: #f56c6c; }
|
||||
.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 {
|
||||
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;
|
||||
@@ -379,9 +383,9 @@ function getKrProgressText(kr: any): string {
|
||||
.rate-green { color: #67c23a; }
|
||||
.rate-yellow { color: #e6a23c; }
|
||||
.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; }
|
||||
.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 {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
@@ -403,7 +407,7 @@ function getKrProgressText(kr: any): string {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.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 {
|
||||
flex: 1; max-width: 90px; height: 6px; background: #e4e7ed;
|
||||
@@ -476,7 +480,7 @@ function getKrProgressText(kr: any): string {
|
||||
margin-bottom: 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e8f0fe;
|
||||
background: #f8faff;
|
||||
background: var(--bh-bg-main);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
@@ -513,7 +517,7 @@ function getKrProgressText(kr: any): string {
|
||||
}
|
||||
.kr-sub-target {
|
||||
font-size: 10px;
|
||||
color: #909399;
|
||||
color: var(--bh-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* KR 删除入口:hover 才显形,避免与 KR 文本拥挤;键盘聚焦时同样可见(可访问性) */
|
||||
@@ -528,7 +532,7 @@ function getKrProgressText(kr: any): string {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #c0c4cc;
|
||||
color: var(--bh-placeholder);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity .15s, color .15s, background .15s, border-color .15s;
|
||||
@@ -571,14 +575,14 @@ function getKrProgressText(kr: any): string {
|
||||
.kr-sub-progress-text {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
color: var(--bh-text-regular);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.kr-sub-weight {
|
||||
font-size: 10px;
|
||||
color: #909399;
|
||||
color: var(--bh-text-secondary);
|
||||
background: #f0f5ff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
@@ -590,7 +594,7 @@ function getKrProgressText(kr: any): string {
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 8px;
|
||||
color: #909399;
|
||||
color: var(--bh-text-secondary);
|
||||
font-size: 12px;
|
||||
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>
|
||||
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: 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-name">{{ label }}</span>
|
||||
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
|
||||
@@ -15,7 +26,7 @@
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="layer-body" ref="bodyRef">
|
||||
<div v-show="!collapsed" class="layer-body" ref="bodyRef">
|
||||
<!-- vuedraggable: 每层独立group → 禁止跨层拖拽 -->
|
||||
<draggable
|
||||
v-if="objectives.length > 0"
|
||||
@@ -23,6 +34,7 @@
|
||||
:group="layerKey"
|
||||
item-key="name"
|
||||
:animation="150"
|
||||
@start="onDragStart"
|
||||
@end="onDragEnd"
|
||||
class="layer-draggable"
|
||||
>
|
||||
@@ -44,7 +56,7 @@
|
||||
:icon-map="iconMap"
|
||||
:kpi-name-map="kpiNameMap"
|
||||
:all-kpis="allKpis"
|
||||
:chain-dimmed="chainNodeKeys.length > 0 && !chainNodeKeys.includes(`${layerKey}-${index}`)"
|
||||
:chain-dimmed="isDimmed(`${layerKey}-${index}`)"
|
||||
@click="onNodeClick(layerKey, index, element)"
|
||||
@edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })"
|
||||
@delete="$emit('delete-objective', { dimKey: layerKey, idx: index })"
|
||||
@@ -62,9 +74,9 @@
|
||||
|
||||
<!-- 空状态占位提示 -->
|
||||
<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>
|
||||
</el-empty>
|
||||
</div>
|
||||
@@ -98,6 +110,11 @@ const props = defineProps<{
|
||||
allKpis?: any[]
|
||||
/** 因果链查看模式下,不在链上的节点变淡 */
|
||||
chainNodeKeys?: string[]
|
||||
/** 搜索/筛选激活时:不在匹配集合内的节点变淡 */
|
||||
dimActive?: boolean
|
||||
dimmedKeys?: string[]
|
||||
/** 层折叠(仅隐藏层体,保留层头) */
|
||||
collapsed?: boolean
|
||||
getNodeLevel: (key: string) => string
|
||||
getNodeProgress: (key: string) => any
|
||||
}>()
|
||||
@@ -116,6 +133,8 @@ const emit = defineEmits<{
|
||||
(e: 'update:label', val: string): void
|
||||
(e: 'update:color', 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: '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
|
||||
@@ -136,6 +155,17 @@ function onLayerAction(cmd: string) {
|
||||
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 触发)
|
||||
* 由于 :list 传递的是父级响应式数组的引用,SortableJS 已直接完成了数组重排,
|
||||
@@ -164,7 +194,7 @@ export default {
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
background: var(--bh-bg-card);
|
||||
min-height: 120px;
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,35 @@
|
||||
@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 来自会计科目、非发票同票列明 -->
|
||||
<div v-if="caliberFacts" class="caliber-strip">
|
||||
<CaliberBadge kind="account_invoice_gap" :facts="caliberFacts" />
|
||||
@@ -35,49 +64,72 @@
|
||||
|
||||
<!-- 因果链查看模式提示 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 连线模式提示 -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="!loading && dimensions.every(d => d.objectives.length === 0)" class="empty-state">
|
||||
<el-empty description="四层泳道已就绪,点击各层的「+ 添加目标」开始填写">
|
||||
<template #image>
|
||||
<svg viewBox="0 0 120 120" width="120" height="120">
|
||||
<rect x="10" y="10" width="100" height="22" rx="4" fill="#FEF0F0" />
|
||||
<rect x="10" y="38" width="100" height="22" rx="4" fill="#ECF5FF" />
|
||||
<rect x="10" y="66" width="100" height="22" rx="4" fill="#F0F9EB" />
|
||||
<rect x="10" y="94" width="100" height="22" rx="4" fill="#FDF6EC" />
|
||||
</svg>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
<!-- 仪表盘主视图:左目标树 / 中画布 / 右详情 -->
|
||||
<div class="map-body-row">
|
||||
<MapTargetTree
|
||||
v-show="treeOpen"
|
||||
:groups="treeGroups"
|
||||
:search="searchText"
|
||||
:filter="filterMode"
|
||||
:active-key="selectedKey"
|
||||
:collapsed="collapsedGroups"
|
||||
:visible-count="visibleObjectiveCount"
|
||||
:total-count="totalObjectiveCount"
|
||||
@select="onTreeNodeSelect"
|
||||
@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 缩放) -->
|
||||
<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-body" :class="{ 'grid-mode': gridMode }" ref="bodyRef" :style="bodyZoomStyle">
|
||||
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
|
||||
<div
|
||||
v-for="(cfg, idx) in orderedLayerConfigs"
|
||||
:key="cfg.key"
|
||||
v-show="visibleLayers[cfg.key]"
|
||||
:ref="el => setLayerRef(cfg.key, el)"
|
||||
class="layer-swimlane-wrapper"
|
||||
:style="{ borderLeft: '4px solid ' + cfg.color }"
|
||||
:style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }"
|
||||
>
|
||||
<StrategyLayer
|
||||
:ref="el => setLayerCompRef(cfg.key, el)"
|
||||
:layer-key="cfg.key"
|
||||
:label="cfg.label"
|
||||
:icon="cfg.icon"
|
||||
:color="cfg.color"
|
||||
:icon="''"
|
||||
:color="layerColor(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"
|
||||
:drag-connect-target="dragConnectTarget"
|
||||
:link-eligibility-map="linkEligibilityMap"
|
||||
@@ -114,6 +166,44 @@
|
||||
</div><!-- end canvas-zoom-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>风险 <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 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
|
||||
<ConnectionLines
|
||||
ref="connectionLinesRef"
|
||||
@@ -177,7 +267,7 @@
|
||||
@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">
|
||||
<div class="kr-del-confirm-text">
|
||||
确定删除关键结果「<strong>{{ krDeleteTarget?.name }}</strong>」?
|
||||
@@ -190,6 +280,21 @@
|
||||
<el-button size="small" type="danger" :loading="krDeleting" @click="confirmDeleteKr">删除</el-button>
|
||||
</template>
|
||||
</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>
|
||||
<!-- P1-3: 关联知识点 -->
|
||||
<div style="margin-top:12px;">
|
||||
@@ -200,7 +305,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, onMounted, nextTick, onUnmounted } from "vue"
|
||||
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 KnowledgePanel from '../components/KnowledgePanel.vue'
|
||||
import api from "../api/index"
|
||||
@@ -208,6 +313,11 @@ import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
|
||||
import { useFormGuard } from "../composables/useFormGuard"
|
||||
import { sanitizeKrName } from "../utils/krName"
|
||||
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 MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue"
|
||||
import MapDimensionFilter from "../components/map-canvas/MapDimensionFilter.vue"
|
||||
@@ -251,6 +361,46 @@ const loading = ref(true)
|
||||
// 口径标识:仅登记过缺票事实的账套(当前为 entity 2 陕西博海)才挂「科目口径 · 缺票」
|
||||
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 linkingFrom = ref<{ key: string; obj: any } | null>(null)
|
||||
@@ -286,6 +436,7 @@ function onChainFocus(nodes: string[] | null) { chainFocusNodes.value = nodes }
|
||||
/** 更新连线语义(effect/label),落库 */
|
||||
function onUpdateConnection(idx: number, patch: any) {
|
||||
if (!connections.value[idx]) return
|
||||
pushUndo()
|
||||
Object.assign(connections.value[idx], patch)
|
||||
markDirty()
|
||||
saveConnections()
|
||||
@@ -334,14 +485,107 @@ function updateGridMode() {
|
||||
// 节点状态(红黄绿灯)
|
||||
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 {
|
||||
ratio: number
|
||||
/** 达成率百分比(0-…),无数据为 null */
|
||||
ratio: number | null
|
||||
level: string
|
||||
kpiList: { code: string; name: string; actual: number|null; target: number|null; unit: string|null }[]
|
||||
planSummary: { total: number; pending: number; in_progress: number; completed: number; overdue: number } | null
|
||||
kpiList: KpiListItem[]
|
||||
planSummary: any | null
|
||||
/** 有实测值/有实测值 KPI 数 */
|
||||
hasData?: boolean
|
||||
kpiTotal?: number
|
||||
kpiWithData?: number
|
||||
}
|
||||
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 维度切片(时间/门店/产品)──────────────────────────────
|
||||
// 切片激活时用它覆盖 objectiveLevels / kpiProgressMap 的展示值:
|
||||
// - available=false 的 KPI 一律 null,不用总额或其它期间冒充
|
||||
@@ -408,6 +652,12 @@ function onSaveDialog(data: any) {
|
||||
const showVersions = ref(false)
|
||||
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 causalityRecommendations = ref<any[]>([])
|
||||
const causalityLoading = ref(false)
|
||||
@@ -559,6 +809,8 @@ function loadCanvas() {
|
||||
chainFocusKey.value = null
|
||||
chainHoverNodes.value = null
|
||||
chainFocusNodes.value = null
|
||||
selectedNode.value = null
|
||||
reviewSummary.value = null
|
||||
loading.value = true
|
||||
|
||||
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
|
||||
return
|
||||
}
|
||||
// 有KPI则展示详情
|
||||
if (kpiProgressMap[key]?.kpiList?.length > 0) {
|
||||
showKpiDetailModal(dimKey, oi, obj)
|
||||
} else {
|
||||
openEditDialog(dimKey, oi, obj)
|
||||
// 常规:选中节点 → 右侧详情面板(2026-10-18)
|
||||
selectNode(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 在拖拽排序后触发
|
||||
* SortableJS 已通过 :list 引用直接更新了响应式数组,
|
||||
@@ -665,6 +1060,7 @@ function completeConnection(fromKey: string, toKey: string) {
|
||||
if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) {
|
||||
ElMessage.warning("已存在相同连线"); cancelLink(); return
|
||||
}
|
||||
pushUndo()
|
||||
api.post(`/maps/${selectedMap.value}/connections`, {
|
||||
from: fromKey, to: toKey,
|
||||
}).then((r: any) => {
|
||||
@@ -833,6 +1229,7 @@ async function onManagerAddConnection(payload: { from: string; to: string; effec
|
||||
function onManagerDeleteConnection(idx: number) {
|
||||
const conn = connections.value[idx]
|
||||
if (!conn) return
|
||||
pushUndo()
|
||||
api.delete(`/maps/${selectedMap.value}/connections`, { data: { from: conn.from, to: conn.to } })
|
||||
.then((r: any) => {
|
||||
if (r?.updated_at) baseUpdatedAt.value = r.updated_at
|
||||
@@ -855,6 +1252,7 @@ function onDeleteConnection(idx: number) {
|
||||
if (selectedConnIdx.value !== idx) return
|
||||
const conn = connections.value[idx]
|
||||
if (!conn) return
|
||||
pushUndo()
|
||||
api.delete(`/maps/${selectedMap.value}/connections`, {
|
||||
data: { from: conn.from, to: conn.to },
|
||||
}).then((r: any) => {
|
||||
@@ -872,7 +1270,8 @@ function onDeleteConnection(idx: number) {
|
||||
|
||||
// ── 节点CRUD ──
|
||||
function openAddDialog(layerKey: string) {
|
||||
editingLayerKey.value = layerKey
|
||||
const key = layerKey && LAYER_CONFIG[layerKey] ? layerKey : 'finance'
|
||||
editingLayerKey.value = key
|
||||
editingNodeData.value = null
|
||||
dialogForm.name = ''
|
||||
dialogForm.description = ''
|
||||
@@ -911,6 +1310,7 @@ function openEditDialog(dimKey: string, oi: number, obj: any) {
|
||||
}
|
||||
|
||||
function onNodeSave(data: any) {
|
||||
pushUndo()
|
||||
const layer = editingLayerKey.value || data.layer || data.dimKey
|
||||
const dim = dimensions.find((d: any) => d.key === layer)
|
||||
if (!dim) return
|
||||
@@ -1042,6 +1442,7 @@ async function deleteNode(dimKey: string, oi: number) {
|
||||
const dim = dimensions.find((d: any) => d.key === dimKey)
|
||||
if (dim) {
|
||||
const key = `${dimKey}-${oi}`
|
||||
pushUndo()
|
||||
connections.value = connections.value.filter(c => c.from !== key && c.to !== key)
|
||||
dim.objectives.splice(oi, 1)
|
||||
ElMessage.success("节点已删除")
|
||||
@@ -1065,6 +1466,7 @@ function onAddKr(dimKey: string, oi: number, obj: any) {
|
||||
ElMessage.warning('每个目标最多5个关键结果')
|
||||
return
|
||||
}
|
||||
pushUndo()
|
||||
target.krs.push({
|
||||
name: "",
|
||||
target_value: "",
|
||||
@@ -1118,6 +1520,7 @@ async function confirmDeleteKr() {
|
||||
const krId: number | null = kr._kr_id || kr.id || null
|
||||
const removedName = kr.name || `KR ${t.ki + 1}`
|
||||
// 1) 本地移除(界面即时无残留)
|
||||
pushUndo()
|
||||
target.krs.splice(t.ki, 1)
|
||||
cancelDeleteKr()
|
||||
try {
|
||||
@@ -1384,13 +1787,22 @@ async function loadVersions() {
|
||||
}
|
||||
|
||||
async function rollback(verId: number) {
|
||||
pendingRollbackVerId.value = verId
|
||||
showRollbackConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmRollback() {
|
||||
if (pendingRollbackVerId.value == null) return
|
||||
rollbacking.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。")
|
||||
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
|
||||
await api.post(`/maps/${selectedMap.value}/versions/${pendingRollbackVerId.value}/rollback`)
|
||||
ElMessage.success("回滚成功,刷新页面")
|
||||
showRollbackConfirm.value = false
|
||||
window.location.reload()
|
||||
} 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
|
||||
try {
|
||||
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(kpiProgressMap)) delete kpiProgressMap[key]
|
||||
for (const key of Object.keys(trendMap)) delete trendMap[key]
|
||||
const dims = r.dimensions || []
|
||||
for (const dim of dims) {
|
||||
const dimKey = dim.key
|
||||
const localDim = dimensions.find((d: any) => d.key === dimKey)
|
||||
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}`
|
||||
kpiProgressMap[key] = { ratio: 0, level: 'gray', kpiList: [], planSummary: null }
|
||||
objectiveLevels[key] = 'gray'
|
||||
}
|
||||
dim.objectives.forEach((obj: any) => {
|
||||
const localIdx = localDim.objectives.findIndex((o: any) => o.name === obj.name)
|
||||
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 }
|
||||
}
|
||||
const localObj = localDim.objectives[i]
|
||||
let robj = reviewObjs[i]
|
||||
// 后端按目标顺序返回(含无 KPI 目标)⇒ 索引对齐;名称不一致时按名称兜底
|
||||
if (robj && localObj && robj.name !== localObj.name) {
|
||||
robj = reviewObjs.find((o: any) => o.name === localObj.name) || robj
|
||||
}
|
||||
})
|
||||
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 */ }
|
||||
}
|
||||
@@ -1495,6 +1915,23 @@ function goAlignment() {
|
||||
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%导出) */
|
||||
async function exportImage() {
|
||||
const el = document.querySelector('.canvas-scroll-wrap') as HTMLElement
|
||||
@@ -1568,6 +2005,7 @@ async function applyAllCausality() {
|
||||
|
||||
// 滚动触发连线重算(rAF节流,避免滚动/拖拽时卡顿)
|
||||
let scrollRaf = 0
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
function onCanvasScroll() {
|
||||
if (scrollRaf) return
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
@@ -1580,6 +2018,12 @@ function onWindowResize() {
|
||||
updateZoomWrapHeight()
|
||||
triggerRecalc()
|
||||
}
|
||||
/** 账套切换(同浏览器跨标签页):entity 变化即整页重载,杜绝跨实体混数据 */
|
||||
function onStorageEntityChange(e: StorageEvent) {
|
||||
if (e.key === 'cma_entity_id' && e.newValue !== e.oldValue) {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await mapApi.list()
|
||||
@@ -1595,6 +2039,7 @@ onMounted(async () => {
|
||||
ensureFourLayers()
|
||||
loading.value = false
|
||||
}
|
||||
void loadFinanceSummary()
|
||||
const k: any = await kpiApi.list({ page_size: 100 })
|
||||
allKpis.value = k.data || []
|
||||
for (const kpi of allKpis.value) {
|
||||
@@ -1616,12 +2061,24 @@ onMounted(async () => {
|
||||
})
|
||||
scrollWrapRef.value?.addEventListener('scroll', onCanvasScroll, { passive: true })
|
||||
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(() => {
|
||||
disposed = true // 先置位:阻断仍在 await 中的 onMounted 续段再注册定时器(防泄漏)
|
||||
scrollWrapRef.value?.removeEventListener('scroll', onCanvasScroll)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
window.removeEventListener('storage', onStorageEntityChange)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
stopAutoSave()
|
||||
})
|
||||
@@ -1647,6 +2104,8 @@ onUnmounted(() => {
|
||||
color: #b88230;
|
||||
}
|
||||
.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 {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
@@ -1727,7 +2186,62 @@ onUnmounted(() => {
|
||||
.kr-del-confirm-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
color: var(--bh-text-secondary);
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user