feat(strategy-map): live board reading map_objectives + kpi_targets (read-only)
Task: finance-20261011-strategy-map-frontend-fullstack (P2, read-only integration)
- backend/app/utils/strategy_board.py: single source of truth for the level
caliber (layer colors, inverse-kpi explicit whitelist, status class by
unit, zero guard, period ranking, latest-actual picking). Pure functions.
- backend/app/api/maps.py: GET /api/cma/maps/{map_id}/strategy-board
- nodes straight from map_objectives (sorted by sort_order, id)
- node<->kpi binding with 4-step fallback, binding_source echoed back
- targets from kpi_targets (target_type compatible: yearly + annual),
deduped per (kpi_id, period) with yearly preferred
- actuals from kpi_values actual-only scope, latest period
- causality edges from kpi_causality, out-of-map endpoints dropped+counted
- entity_id taken from the token only (no cross-tenant reads)
- zero writes; nothing is persisted by this endpoint
- frontend/src/views/StrategyMap.vue: /strategy-map live board (4 swim lanes
bottom-up, node cards, 20 causality arrows in SVG, period switch, A3 print).
No emoji, null guards everywhere (no toFixed on null).
- api/index.ts + router/index.ts + permission.ts: route + menu registration
(/strategy-map, zone A read-only, roles ceo/finance/business/it).
- backend/tests/test_strategy_board_20261011.py: 33 cases (spec section 4 /
4.1 / 4.2 / 4.3 + section 7 fixtures + threshold boundaries + period rank).
- backend/tests/test_map_objectives_kpis_form_20261011.py: 14 cases for the
four kpis column shapes (list, json string, object list, NULL/[]/weird).
Not included: push + backend restart (production write, separate gate).
(cherry picked from commit c675bde6b4beaf1d8a9d02a80f5434203a53f348)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""战略地图 API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
@@ -1227,3 +1228,320 @@ def get_review_record(
|
||||
raise HTTPException(404, "复盘记录不存在")
|
||||
return review_to_dict(r)
|
||||
|
||||
|
||||
# ── 战略地图「活看板」(只读)────────────────────────────────────────
|
||||
# 任务:finance-20261011-strategy-map-frontend-fullstack(任总 2026-10-11 19:07「收口」)
|
||||
# 口径单一源:app/utils/strategy_board.py(色阶/逆指标白名单/状态类/零点保护)
|
||||
# 只读:本端点不写任何表;entity_id 严格取 token → 博海 e2 天然隔离。
|
||||
|
||||
# kpi_targets.target_type 取值:兼容期读两形(不阻塞渲染)
|
||||
# - 现状(2026-10-11 只读实测):全库仅 `annual: 57`(e1 战略框架同步写入,值域外)
|
||||
# - P0 修正单 finance-20261011-kpi-targets-targettype-fix-dama 会把它们改成 `yearly`
|
||||
# - 两形都读 ⇒ 修正前后同一份代码都取得到数;同 (kpi_id, period) 重复时偏好 `yearly`
|
||||
_PERIOD_TYPES = ("yearly", "annual")
|
||||
|
||||
|
||||
def _sb_resolve_bindings(db: Session, entity_id: int, objs: list) -> tuple[dict, dict]:
|
||||
"""节点 ↔ KPI 绑定:优先 `map_objectives.kpis`(收口单已回填),缺失时按名称回查。
|
||||
|
||||
返回 (bindings, sources):``bindings[obj.id] = (kpi_id, kpi_code)``;
|
||||
``sources[obj.id]`` ∈ {kpis, kpis_code, name, note, none} —— 供复核方判断
|
||||
是否已用上 19 条回填绑定(回填前 name/note 兜底)。
|
||||
"""
|
||||
from app.models import KPIDefinition
|
||||
|
||||
bindings: dict = {}
|
||||
sources: dict = {}
|
||||
declared_codes: dict = {}
|
||||
|
||||
for obj in objs:
|
||||
raw = obj.kpis
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except Exception:
|
||||
raw = None
|
||||
if isinstance(raw, dict):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, list):
|
||||
continue
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
kid, code = item.get("kpi_id"), item.get("kpi_code") or item.get("code")
|
||||
if kid:
|
||||
bindings[obj.id] = (int(kid), code)
|
||||
sources[obj.id] = "kpis"
|
||||
break
|
||||
if code:
|
||||
declared_codes[obj.id] = code
|
||||
break
|
||||
elif isinstance(item, str):
|
||||
declared_codes[obj.id] = item
|
||||
break
|
||||
|
||||
def _def_by_code(code: str, cache: dict):
|
||||
if code in cache:
|
||||
return cache[code]
|
||||
row = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_code == code,
|
||||
).first()
|
||||
cache[code] = row
|
||||
return row
|
||||
|
||||
code_cache: dict = {}
|
||||
for oid, code in declared_codes.items():
|
||||
row = _def_by_code(code, code_cache)
|
||||
if row:
|
||||
bindings[oid] = (row.id, row.kpi_code)
|
||||
sources[oid] = "kpis_code"
|
||||
|
||||
# 兜底:按节点名回查 KPI 名 / kpi_targets.note("名称 · 期间")
|
||||
for obj in objs:
|
||||
if obj.id in bindings:
|
||||
continue
|
||||
row = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.kpi_name == obj.name,
|
||||
).first()
|
||||
if row:
|
||||
bindings[obj.id] = (row.id, row.kpi_code)
|
||||
sources[obj.id] = "name"
|
||||
continue
|
||||
hit = db.execute(text(
|
||||
"SELECT kpi_id FROM kpi_targets "
|
||||
"WHERE entity_id = :e AND target_type IN ('yearly', 'annual') AND note LIKE :pat "
|
||||
"ORDER BY period LIMIT 1"
|
||||
), {"e": entity_id, "pat": f"{obj.name} · %"}).first()
|
||||
if hit:
|
||||
bindings[obj.id] = (int(hit[0]), None)
|
||||
sources[obj.id] = "note"
|
||||
else:
|
||||
sources[obj.id] = "none"
|
||||
return bindings, sources
|
||||
|
||||
|
||||
@router.get("/{map_id}/strategy-board")
|
||||
def get_strategy_board(
|
||||
map_id: int,
|
||||
period: Optional[str] = Query(None, description="期间 2027/2028/2029,默认取最小可用期间"),
|
||||
db: Session = Depends(get_db),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
):
|
||||
"""战略地图活看板(只读):19 节点分层 + 达成率色阶 + 因果箭头。
|
||||
|
||||
- 目标值:`kpi_targets`(`target_type` 兼容 `yearly`/`annual` 两形,按 period)
|
||||
- 实际值:`kpi_values` **实测区**最新期(`kpi_value_scope.actual_only_clause`)
|
||||
- 色阶:r = 实际/目标;≥1.0 success / ≥0.8 warning / <0.8 danger / 无实际值 info
|
||||
- 逆指标(显式白名单 504/511/230/23)取倒数;状态类(unit='状态')按 1/0 判色
|
||||
"""
|
||||
from app.models import KPIValue, KPICausality, KPIDefinition
|
||||
from app.utils.strategy_board import (
|
||||
INVERSE_KPI_IDS, LAYER_DISPLAY_ORDER, LAYER_META, LEVEL_COLORS, STATUS_UNIT,
|
||||
compute_level, layer_meta, period_rank, pick_latest,
|
||||
)
|
||||
|
||||
warnings: list = []
|
||||
m = db.query(StrategicMap).filter(
|
||||
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id,
|
||||
).first()
|
||||
if not m:
|
||||
raise HTTPException(404, "战略地图不存在")
|
||||
|
||||
objs = db.query(MapObjective).filter(
|
||||
MapObjective.map_id == map_id, MapObjective.entity_id == entity_id,
|
||||
).order_by(MapObjective.sort_order, MapObjective.id).all()
|
||||
if not objs:
|
||||
warnings.append("该地图下无 map_objectives 节点(map_objectives 为空)")
|
||||
|
||||
bindings, sources = _sb_resolve_bindings(db, entity_id, objs)
|
||||
kpi_ids = sorted({v[0] for v in bindings.values() if v and v[0]})
|
||||
kpi_defs = {}
|
||||
if kpi_ids:
|
||||
for k in db.query(KPIDefinition).filter(
|
||||
KPIDefinition.entity_id == entity_id, KPIDefinition.id.in_(kpi_ids),
|
||||
).all():
|
||||
kpi_defs[k.id] = k
|
||||
|
||||
# ── 目标值(kpi_targets 无 ORM 模型,用只读 text() 取)──
|
||||
targets: dict = {}
|
||||
periods: list = []
|
||||
if kpi_ids:
|
||||
id_list = ",".join(str(int(i)) for i in kpi_ids) # 已 int 化,无注入面
|
||||
ttype = ",".join("'%s'" % t for t in _PERIOD_TYPES)
|
||||
rows = db.execute(text(
|
||||
"SELECT kpi_id, period, target_type, target_value FROM kpi_targets "
|
||||
f"WHERE entity_id = :e AND target_type IN ({ttype}) AND kpi_id IN ({id_list})"
|
||||
), {"e": entity_id}).fetchall()
|
||||
# 同 (kpi_id, period) 两形并存(兼容期)⇒ 取 `_PERIOD_TYPES` 中序更靠前者(yearly 优先)
|
||||
_type_rank = {t: i for i, t in enumerate(_PERIOD_TYPES)}
|
||||
best: dict = {}
|
||||
for r in rows:
|
||||
kid, per, ttype_row, tv = int(r[0]), str(r[1]), str(r[2]), r[3]
|
||||
rank = _type_rank.get(ttype_row, len(_PERIOD_TYPES))
|
||||
if (kid, per) in best and best[(kid, per)][0] <= rank:
|
||||
continue
|
||||
best[(kid, per)] = (rank, float(tv) if tv is not None else None)
|
||||
for (kid, per) in best:
|
||||
targets[(kid, per)] = best[(kid, per)][1]
|
||||
if per not in periods:
|
||||
periods.append(per)
|
||||
periods.sort(key=period_rank)
|
||||
|
||||
# `direction` 列在 ORM 模型里未映射(库内有、模型无);判色**不依赖**它
|
||||
# (逆指标用显式白名单,见 strategy_board.INVERSE_KPI_IDS),此处仅为回执留证
|
||||
directions: dict = {}
|
||||
if kpi_ids:
|
||||
id_list_d = ",".join(str(int(i)) for i in kpi_ids)
|
||||
for r in db.execute(text(
|
||||
f"SELECT id, direction FROM kpi_definitions WHERE id IN ({id_list_d})"
|
||||
)).fetchall():
|
||||
directions[int(r[0])] = r[1]
|
||||
|
||||
# ── 实际值:实测区最新期(每 KPI 一条)──
|
||||
currents: dict = {}
|
||||
if kpi_ids:
|
||||
vrows = db.query(KPIValue).filter(
|
||||
KPIValue.entity_id == entity_id,
|
||||
KPIValue.kpi_id.in_(kpi_ids),
|
||||
actual_only_clause(),
|
||||
).all()
|
||||
by_kpi: dict = {}
|
||||
for v in vrows:
|
||||
by_kpi.setdefault(v.kpi_id, []).append(v)
|
||||
for kid, rows_k in by_kpi.items():
|
||||
currents[kid] = pick_latest(rows_k)
|
||||
|
||||
# ── 因果箭头(先按本单 source_type,为空则退回该实体全部)──
|
||||
edges_raw = db.query(KPICausality).filter(
|
||||
KPICausality.entity_id == entity_id,
|
||||
KPICausality.source_type == "strategy-2027",
|
||||
).all()
|
||||
if not edges_raw:
|
||||
edges_raw = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
|
||||
if edges_raw:
|
||||
warnings.append("未命中 source_type='strategy-2027' 的因果链,已退回该实体全部因果链")
|
||||
|
||||
chosen_period = period if period else (periods[0] if periods else None)
|
||||
obj_by_kpi = {v[0]: k for k, v in bindings.items() if v and v[0]}
|
||||
|
||||
nodes: list = []
|
||||
for obj in objs:
|
||||
kid, code = bindings.get(obj.id, (None, None))
|
||||
kdef = kpi_defs.get(kid) if kid else None
|
||||
unit = (kdef.unit if kdef else None) or None
|
||||
target = targets.get((kid, chosen_period)) if kid else None
|
||||
vrow = currents.get(kid) if kid else None
|
||||
cur = float(vrow.actual_value) if (vrow and vrow.actual_value is not None) else None
|
||||
calc = compute_level(kpi_id=kid, unit=unit, current=cur, target=target)
|
||||
lmeta = layer_meta(obj.dimension_key)
|
||||
owner = None
|
||||
if kdef:
|
||||
owner = getattr(kdef, "responsible_user", None) or getattr(kdef, "data_owner", None)
|
||||
dept = getattr(kdef, "responsible_dept", None)
|
||||
if dept and owner:
|
||||
owner = f"{dept}·{owner}"
|
||||
elif dept:
|
||||
owner = dept
|
||||
nodes.append({
|
||||
"id": obj.id,
|
||||
"name": obj.name,
|
||||
"dimension_key": obj.dimension_key,
|
||||
"layer_name": lmeta["name"],
|
||||
"layer_color": lmeta["color"],
|
||||
"icon": obj.icon,
|
||||
"sort_order": obj.sort_order,
|
||||
"description": obj.description,
|
||||
"kpi_id": kid,
|
||||
"kpi_code": code or (kdef.kpi_code if kdef else None),
|
||||
"kpi_name": kdef.kpi_name if kdef else None,
|
||||
"unit": unit,
|
||||
"direction": directions.get(kid),
|
||||
"owner": owner,
|
||||
"binding_source": sources.get(obj.id, "none"),
|
||||
"current": cur,
|
||||
"current_period": (vrow.period if vrow else None),
|
||||
"current_data_status": (vrow.data_status if vrow else None),
|
||||
"current_source_type": (vrow.source_type if vrow else None),
|
||||
"target": target,
|
||||
"rate": calc["rate"],
|
||||
"level": calc["level"],
|
||||
"color": calc["color"],
|
||||
"mode": calc["mode"],
|
||||
"reason": calc["reason"],
|
||||
})
|
||||
if obj.id not in bindings or not kid:
|
||||
warnings.append(f"节点「{obj.name}」未绑定 KPI(map_objectives.kpis 为空且回查未命中)")
|
||||
elif code is None and kdef:
|
||||
code = kdef.kpi_code
|
||||
|
||||
layer_buckets: dict = {k: [] for k in LAYER_DISPLAY_ORDER}
|
||||
for n in nodes:
|
||||
layer_buckets.setdefault(n["dimension_key"] if n["dimension_key"] in LAYER_META else "unknown", []).append(n)
|
||||
|
||||
layers: list = []
|
||||
for key in LAYER_DISPLAY_ORDER:
|
||||
lmeta = LAYER_META[key]
|
||||
layers.append({"key": key, "name": lmeta["name"], "color": lmeta["color"],
|
||||
"nodes": layer_buckets.get(key, [])})
|
||||
for key, bucket in layer_buckets.items():
|
||||
if key not in LAYER_META and bucket:
|
||||
layers.append({"key": key, "name": key, "color": LEVEL_COLORS["info"], "nodes": bucket})
|
||||
warnings.append(f"存在规范外 dimension_key='{key}'({len(bucket)} 个节点)")
|
||||
|
||||
edges: list = []
|
||||
dropped = 0
|
||||
for e in edges_raw:
|
||||
sid, tid = obj_by_kpi.get(e.source_kpi_id), obj_by_kpi.get(e.target_kpi_id)
|
||||
if sid is None or tid is None:
|
||||
dropped += 1
|
||||
continue
|
||||
edges.append({
|
||||
"id": e.id,
|
||||
"source_node_id": sid, "target_node_id": tid,
|
||||
"source_kpi_id": e.source_kpi_id, "target_kpi_id": e.target_kpi_id,
|
||||
"strength": float(e.strength) if e.strength is not None else None,
|
||||
"lag_months": e.lag_months,
|
||||
"direction": e.direction,
|
||||
"formula": e.formula,
|
||||
})
|
||||
if dropped:
|
||||
warnings.append(f"{dropped} 条因果链端点不在本图 19 节点内,已剔除")
|
||||
|
||||
asof = None
|
||||
asof_rank = -1
|
||||
for v in currents.values():
|
||||
if period_rank(v.period) > asof_rank:
|
||||
asof, asof_rank = v.period, period_rank(v.period)
|
||||
|
||||
with_actual = sum(1 for n in nodes if n["current"] is not None)
|
||||
return {
|
||||
"map": {
|
||||
"id": m.id, "entity_id": m.entity_id, "title": m.title,
|
||||
"version": m.version, "status": m.status,
|
||||
"updated_at": m.updated_at.isoformat() if getattr(m, "updated_at", None) else None,
|
||||
},
|
||||
"period": chosen_period,
|
||||
"periods": periods,
|
||||
"data_asof": asof,
|
||||
"layers": layers,
|
||||
"edges": edges,
|
||||
"stats": {
|
||||
"nodes": len(nodes),
|
||||
"with_actual": with_actual,
|
||||
"missing_actual": len(nodes) - with_actual,
|
||||
"edges": len(edges),
|
||||
"by_level": {lv: sum(1 for n in nodes if n["level"] == lv) for lv in LEVEL_COLORS},
|
||||
},
|
||||
"caliber": {
|
||||
"source": "app/utils/strategy_board.py",
|
||||
"level_rule": "r=actual/target;>=1.0 success;>=0.8 warning;<0.8 danger;no actual info",
|
||||
"inverse_whitelist": sorted(INVERSE_KPI_IDS),
|
||||
"status_unit": STATUS_UNIT,
|
||||
"actual_scope": "kpi_values 实测区(排除 estimated/forecast)最新期",
|
||||
"zero_guard": "target 0/缺失 ⇒ info,禁 NaN/Inf",
|
||||
},
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""战略地图「活看板」色阶口径 —— 单一事实源(任务 finance-20261011-strategy-map-frontend-fullstack)
|
||||
|
||||
口径依据(逐字,来源见任务件 §三/§四 与 D1 裁定):
|
||||
|
||||
| 层 | dimension_key | 层色 |
|
||||
|---|---|---|
|
||||
| 财务(滞后) | finance | #F56C6C |
|
||||
| 客户(过渡) | customer | #409EFF |
|
||||
| 流程(领先) | process | #67C23A |
|
||||
| 学习(根本驱动) | learning | #E6A23C |
|
||||
|
||||
色阶(§四):
|
||||
|
||||
r = 实际值 / 目标值
|
||||
r >= 1.0 -> success #16A34A
|
||||
r >= 0.8 -> warning #EAB308
|
||||
r < 0.8 -> danger #DC2626
|
||||
无实际值 -> info #94A3B8
|
||||
|
||||
特例(**必须**按此三类处理,不要按 direction 字段过滤):
|
||||
|
||||
- §4.1 逆指标 ⇒ 取倒数再算:`INVERSE_KPI_IDS` **显式 code/ID 白名单**。
|
||||
不用 `direction='lower_better'` 过滤 —— 库内 `#230 EXT_139` / `#23 C_CUST_CONCENTRATION`
|
||||
的 `direction` 为 **NULL**(dama 知会件 `dama-20261011-strategy-map-direction-contract-fyi`),
|
||||
照 direction 过滤会把这两条逆指标错算成正向。
|
||||
- §4.2 状态类 ⇒ 不走达成率:判据是 **`unit == '状态'`**(不是 `direction IS NULL`,
|
||||
逆指标 #230/#23 的 direction 同样是 NULL)。约定 **1 ⇒ success;0 / NULL ⇒ danger**,
|
||||
并且**绝不**做 `current / target`(避免除零)。
|
||||
- §4.3 零点保护:目标值 0 / 缺失 ⇒ info(灰),禁止 NaN / Infinity / 除零崩溃。
|
||||
|
||||
本模块为纯函数(无 DB / 无框架依赖),供 `api/maps.py` 与单测共用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
# ── 层色规范(§三,固定,不可自定义)────────────────────────────
|
||||
LAYER_META: dict[str, dict[str, str]] = {
|
||||
"finance": {"key": "finance", "name": "财务(滞后)", "color": "#F56C6C"},
|
||||
"customer": {"key": "customer", "name": "客户(过渡)", "color": "#409EFF"},
|
||||
"process": {"key": "process", "name": "流程(领先)", "color": "#67C23A"},
|
||||
"learning": {"key": "learning", "name": "学习(根本驱动)", "color": "#E6A23C"},
|
||||
}
|
||||
|
||||
#: 展示顺序:自下而上 学习 → 流程 → 客户 → 财务(§五.1)
|
||||
LAYER_DISPLAY_ORDER: tuple[str, ...] = ("learning", "process", "customer", "finance")
|
||||
|
||||
#: 色阶四级色(§四)
|
||||
LEVEL_COLORS: dict[str, str] = {
|
||||
"success": "#16A34A",
|
||||
"warning": "#EAB308",
|
||||
"danger": "#DC2626",
|
||||
"info": "#94A3B8",
|
||||
}
|
||||
|
||||
#: 逆指标白名单(§4.1 + D1 裁定 §二:CCC(504) / 库存(511) / 短期借款 EXT_139(230) / 客户集中度(23))
|
||||
#: 显式写死,不从 direction 推导(理由见模块 docstring)
|
||||
INVERSE_KPI_IDS: frozenset[int] = frozenset({504, 511, 230, 23})
|
||||
|
||||
#: 状态类判据(§4.2:按 unit 判,不按 direction 判)
|
||||
STATUS_UNIT = "状态"
|
||||
|
||||
#: 达成率阈值(§四)
|
||||
WARNING_RATE = 0.8
|
||||
SUCCESS_RATE = 1.0
|
||||
|
||||
#: 取值状态优先级(同期多行时取更可信的一条)
|
||||
STATUS_RANK: dict[str, int] = {"verified": 3, "calculated": 2, "pending": 1}
|
||||
|
||||
_SUB_PERIOD_MONTHS = {"Q1": 3, "Q2": 6, "Q3": 9, "Q4": 12, "H1": 6, "H2": 12}
|
||||
|
||||
|
||||
class StrategyBoardError(ValueError):
|
||||
"""口径层显式错误(不静默兜底)"""
|
||||
|
||||
|
||||
def period_rank(period: Optional[str]) -> int:
|
||||
"""期间可比排序键:统一折成「年内月序」再比,避免字典序把 `2026-H1` 排在 `2026-08` 之后。
|
||||
|
||||
- ``YYYY-MM`` → year*12 + MM
|
||||
- ``YYYY-Qn`` → year*12 + 3n
|
||||
- ``YYYY-Hn`` → year*12 + 6n
|
||||
- ``YYYY`` → year*12 + 12
|
||||
无法解析 → 0(排在所有可解析期间之前,不参与「最新期」竞争)。
|
||||
"""
|
||||
if not period:
|
||||
return 0
|
||||
raw = str(period).strip()
|
||||
parts = raw.replace("/", "-").split("-")
|
||||
try:
|
||||
year = int(parts[0])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
if len(parts) < 2:
|
||||
return year * 12 + 12
|
||||
sub = parts[1].strip().upper()
|
||||
if sub.isdigit():
|
||||
month = int(sub)
|
||||
if 1 <= month <= 12:
|
||||
return year * 12 + month
|
||||
return 0
|
||||
return year * 12 + _SUB_PERIOD_MONTHS.get(sub, 0)
|
||||
|
||||
|
||||
def status_rank(data_status: Optional[str]) -> int:
|
||||
"""取值状态优先级(同一期间多行时优先 verified)"""
|
||||
return STATUS_RANK.get((data_status or "").strip().lower(), 0)
|
||||
|
||||
|
||||
def pick_latest(rows: Iterable[Any], *, period_attr: str = "period",
|
||||
status_attr: str = "data_status", id_attr: str = "id") -> Optional[Any]:
|
||||
"""从(已按口径过滤的)实测区行里挑「最新期 + 同期间内最可信」的一条。
|
||||
|
||||
排序键 = (期间序, 取值状态序, id)。全部无法解析期间时退回 id,绝不返回随机行。
|
||||
"""
|
||||
best = None
|
||||
best_key = None
|
||||
for row in rows:
|
||||
key = (
|
||||
period_rank(getattr(row, period_attr, None)),
|
||||
status_rank(getattr(row, status_attr, None)),
|
||||
getattr(row, id_attr, 0) or 0,
|
||||
)
|
||||
if best_key is None or key > best_key:
|
||||
best, best_key = row, key
|
||||
return best
|
||||
|
||||
|
||||
def is_status_kpi(unit: Optional[str]) -> bool:
|
||||
"""状态类判据:unit == '状态'(§4.2)"""
|
||||
return (unit or "").strip() == STATUS_UNIT
|
||||
|
||||
|
||||
def is_inverse_kpi(kpi_id: Optional[int]) -> bool:
|
||||
"""逆指标判据:显式白名单(§4.1)"""
|
||||
return kpi_id is not None and int(kpi_id) in INVERSE_KPI_IDS
|
||||
|
||||
|
||||
def compute_level(
|
||||
*,
|
||||
kpi_id: Any = None,
|
||||
unit: Any = None,
|
||||
current: Any = None,
|
||||
target: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""算单节点色阶。返回 ``{level, color, rate, mode, reason}``。
|
||||
|
||||
- ``mode``:``status`` / ``inverse`` / ``normal``
|
||||
- ``rate``:达成率(状态类与灰灯为 ``None``)
|
||||
- ``reason``:灰灯/非达成率的原因(供前端 tooltip 与复核用)
|
||||
|
||||
任何入参形态(None / 0 / 负数 / 非数值字符串)都不得抛异常、不得返回 NaN/Inf。
|
||||
"""
|
||||
mode = "status" if is_status_kpi(unit) else ("inverse" if is_inverse_kpi(kpi_id) else "normal")
|
||||
out: dict[str, Any] = {"level": "info", "color": LEVEL_COLORS["info"], "rate": None,
|
||||
"mode": mode, "reason": None}
|
||||
|
||||
# ── §4.2 状态类:不走达成率 ──
|
||||
if mode == "status":
|
||||
if current is None:
|
||||
# 件 §4.2 逐字:「0 / NULL = 未达成 ⇒ 红」
|
||||
out.update(level="danger", color=LEVEL_COLORS["danger"], reason="状态未填报(NULL ⇒ 未达成)")
|
||||
return out
|
||||
try:
|
||||
val = float(current)
|
||||
except (TypeError, ValueError):
|
||||
out.update(level="danger", color=LEVEL_COLORS["danger"], reason="状态值非法 ⇒ 未达成")
|
||||
return out
|
||||
if val == 1:
|
||||
out.update(level="success", color=LEVEL_COLORS["success"], reason="状态=1(已达成)")
|
||||
else:
|
||||
out.update(level="danger", color=LEVEL_COLORS["danger"], reason=f"状态={val:g}(未达成)")
|
||||
return out
|
||||
|
||||
cur = _to_finite(current)
|
||||
tgt = _to_finite(target)
|
||||
|
||||
# ── §4.3 零点保护 / 无实际值 ──
|
||||
if cur is None:
|
||||
out["reason"] = "无实际值"
|
||||
return out
|
||||
if tgt is None or tgt == 0:
|
||||
out["reason"] = "目标值缺失或为 0"
|
||||
return out
|
||||
|
||||
if mode == "inverse":
|
||||
if cur == 0:
|
||||
# 逆指标现值为 0 ⇒ 倒数无定义,按零点保护置灰(禁除零)
|
||||
out["reason"] = "逆指标现值为 0(倒数无定义)"
|
||||
return out
|
||||
rate = tgt / cur
|
||||
else:
|
||||
rate = cur / tgt
|
||||
|
||||
if rate != rate or rate in (float("inf"), float("-inf")): # NaN / Inf 双保险
|
||||
out["reason"] = "达成率不可计算"
|
||||
return out
|
||||
|
||||
out["rate"] = round(rate, 4)
|
||||
if rate >= SUCCESS_RATE:
|
||||
out.update(level="success", color=LEVEL_COLORS["success"])
|
||||
elif rate >= WARNING_RATE:
|
||||
out.update(level="warning", color=LEVEL_COLORS["warning"])
|
||||
else:
|
||||
out.update(level="danger", color=LEVEL_COLORS["danger"])
|
||||
return out
|
||||
|
||||
|
||||
def _to_finite(value: Any) -> Optional[float]:
|
||||
"""容错取数:None / 空串 / 非数值 → None;NaN / Inf → None(禁入算)"""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if num != num or num in (float("inf"), float("-inf")):
|
||||
return None
|
||||
return num
|
||||
|
||||
|
||||
def layer_meta(dimension_key: Optional[str]) -> dict[str, str]:
|
||||
"""层元信息;未知 dimension_key 归入 info 灰的「未分层」占位(不抛错、不丢节点)"""
|
||||
key = (dimension_key or "").strip()
|
||||
if key in LAYER_META:
|
||||
return LAYER_META[key]
|
||||
return {"key": key or "unknown", "name": key or "未分层", "color": LEVEL_COLORS["info"]}
|
||||
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""`map_objectives.kpis` 读端形态兼容回归(承接 dama 知会件 2026-10-11 19:1x)
|
||||
|
||||
背景:map_id=65 的 19 行 `kpis` 现为 NULL;库内唯一非空先例是**编码字符串数组**
|
||||
(map 53);finance 拟回填的是**对象数组**且未通过形态核验。
|
||||
因此读端(`_sb_resolve_bindings`)必须对四种形态都不抛错、且两形都能正确绑定:
|
||||
|
||||
| 形态 | 样例 | 期望来源 |
|
||||
|---|---|---|
|
||||
| 甲 编码字符串数组 | `["F_REVENUE"]` | kpis_code(按 code 查 kpi_definitions) |
|
||||
| 丙 同上但为 JSON 字符串 | `'["F_REVENUE"]'` | kpis_code |
|
||||
| 乙 对象数组 | `[{"kpi_id":1,"kpi_code":"F_REVENUE"}]` | kpis(直取 kpi_id) |
|
||||
| 乙' 对象数组仅 code | `[{"kpi_code":"F_REVENUE"}]` | kpis_code |
|
||||
| 丁 NULL / [] / 异形 | `None` / `[]` / `"not-json"` | 兜底(name / note / none),不得抛错 |
|
||||
|
||||
同时断言:**不按 status='active' 过滤** kpi_definitions
|
||||
(15 条新建定义 status=NULL,#11 inactive、#230 archived,按 active 过滤会漏)。
|
||||
纯桩测试(FakeDB),不连库、不触生产。
|
||||
"""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.maps import _sb_resolve_bindings
|
||||
from app.models import KPIDefinition
|
||||
|
||||
DEFS = [
|
||||
SimpleNamespace(id=1, entity_id=1, kpi_code="F_REVENUE", kpi_name="营业收入"),
|
||||
SimpleNamespace(id=35, entity_id=1, kpi_code="F_NET_PROFIT", kpi_name="净利润"),
|
||||
# status 刻意置 None:读端不得按 status 过滤
|
||||
SimpleNamespace(id=504, entity_id=1, kpi_code="F_CCC", kpi_name="现金转换周期", status=None),
|
||||
]
|
||||
TARGETS = [(35, "净利润 · 2027")]
|
||||
|
||||
|
||||
def _db() -> Any:
|
||||
"""桩:仅供单测,不连库(运行时鸭子类型,Session 的极小子集)"""
|
||||
return _DB()
|
||||
|
||||
|
||||
def _col_val(arg):
|
||||
try:
|
||||
return arg.left.key, arg.right.value
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
class _Q:
|
||||
def __init__(self, model, store):
|
||||
self.model, self.store, self.eq = model, store, {}
|
||||
|
||||
def filter(self, *args):
|
||||
for a in args:
|
||||
k, v = _col_val(a)
|
||||
if k:
|
||||
self.eq[k] = v
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
for r in self.store.get(self.model, []):
|
||||
if all(getattr(r, k, None) == v for k, v in self.eq.items()):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self):
|
||||
self.store = {KPIDefinition: DEFS}
|
||||
|
||||
def query(self, model):
|
||||
return _Q(model, self.store)
|
||||
|
||||
def execute(self, stmt, params):
|
||||
pat = params.get("pat") or ""
|
||||
|
||||
class _R:
|
||||
def first(self):
|
||||
return next((t for t in TARGETS if pat and t[1].startswith(pat[:-2])), None)
|
||||
return _R()
|
||||
|
||||
|
||||
def _obj(oid, name, kpis):
|
||||
return SimpleNamespace(id=oid, name=name, kpis=kpis, dimension_key="finance")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kpis,expect_id,expect_src", [
|
||||
(["F_REVENUE"], 1, "kpis_code"), # 甲
|
||||
(json.dumps(["F_CCC"]), 504, "kpis_code"), # 丙
|
||||
([{"kpi_id": 35, "kpi_code": "F_NET_PROFIT"}], 35, "kpis"), # 乙
|
||||
([{"kpi_code": "F_NET_PROFIT"}], 35, "kpis_code"), # 乙'
|
||||
([{"code": "F_REVENUE"}], 1, "kpis_code"), # 乙''(code 别名)
|
||||
({"kpi_id": 35}, 35, "kpis"), # 单对象(非数组)
|
||||
])
|
||||
def test_kpis_forms_bind_correctly(kpis, expect_id, expect_src):
|
||||
o = _obj(2001, "任意节点名", kpis)
|
||||
b, s = _sb_resolve_bindings(_db(), 1, [o])
|
||||
assert b.get(2001, (None,))[0] == expect_id, f"form={kpis!r} got={b.get(2001)}"
|
||||
assert s.get(2001) == expect_src, f"form={kpis!r} source={s.get(2001)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kpis", [None, [], "not-json", 12345])
|
||||
def test_kpis_degenerate_forms_fall_back_and_never_raise(kpis):
|
||||
"""丁:NULL / 空 / 异形 —— 走 name/note 兜底或 none,绝不抛异常"""
|
||||
o = _obj(2002, "净利润", kpis) # 名称可回查 + note 兜底均命中 id=35
|
||||
b, s = _sb_resolve_bindings(_db(), 1, [o])
|
||||
assert b.get(2002, (None,))[0] == 35
|
||||
assert s.get(2002) in ("name", "note")
|
||||
|
||||
|
||||
def test_null_kpis_unmatched_node_reports_none_not_crash():
|
||||
"""19 行全 NULL 时:节点仍须在结果里(source=none),不得丢节点/抛错"""
|
||||
o = _obj(2003, "图上没有对应 KPI 的节点", None)
|
||||
b, s = _sb_resolve_bindings(_db(), 1, [o])
|
||||
assert o.id not in b and s.get(o.id) == "none"
|
||||
|
||||
|
||||
def test_no_status_active_filter_on_kpi_definitions():
|
||||
"""status=None 的定义(15 条新建 KPI 形态)必须能被读到 —— 读端不得按 status 过滤"""
|
||||
o = _obj(2004, "现金转换周期", [{"kpi_code": "F_CCC"}])
|
||||
b, _s = _sb_resolve_bindings(_db(), 1, [o])
|
||||
assert b.get(2004, (None,))[0] == 504, "status=None 的定义被过滤掉了(应命中)"
|
||||
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""战略地图活看板色阶口径回归(任务 finance-20261011-strategy-map-frontend-fullstack)
|
||||
|
||||
覆盖件 §四/§4.1/§4.2/§4.3 与 §七「可直接复用的对照值」(2026-01~08 现取)。
|
||||
纯函数测试:不连库、不依赖 FastAPI。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.utils.strategy_board import (
|
||||
INVERSE_KPI_IDS, LAYER_META, LEVEL_COLORS, STATUS_UNIT,
|
||||
compute_level, is_inverse_kpi, is_status_kpi, layer_meta, period_rank, pick_latest,
|
||||
)
|
||||
|
||||
|
||||
# ── §七 对照值(前端自测用,逐条断言期望色)──────────────────────────
|
||||
@pytest.mark.parametrize("name,kpi_id,unit,current,target,expect", [
|
||||
("营业收入", 1, "万元", 808.80, 1050, "danger"), # r=0.77
|
||||
("CM 率", 503, "%", 16.83, 43, "danger"),
|
||||
("CCC(逆)", 504, "天", 206.4, 150, "danger"), # r=0.727 取倒数
|
||||
("货币资金", 505, "万元", 5.31, 60, "danger"),
|
||||
("高贡献线收入占比", 506, "%", 47.81, 55, "warning"), # r=0.87
|
||||
])
|
||||
def test_spec_section7_fixtures(name, kpi_id, unit, current, target, expect):
|
||||
got = compute_level(kpi_id=kpi_id, unit=unit, current=current, target=target)
|
||||
assert got["level"] == expect, f"{name}: {got}"
|
||||
|
||||
|
||||
def test_inverse_ccc_rate_is_reciprocal():
|
||||
"""§4.1 CCC 206.4 → 目标 150:r = 150/206.4 = 0.7267(不是 206.4/150=1.376 的绿灯)"""
|
||||
got = compute_level(kpi_id=504, unit="天", current=206.4, target=150)
|
||||
assert got["mode"] == "inverse"
|
||||
assert got["rate"] == pytest.approx(0.7267, abs=1e-4)
|
||||
assert got["level"] == "danger"
|
||||
# 若误按正向算会得到 success —— 这正是本回归要拦住的假绿
|
||||
assert compute_level(kpi_id=9999, unit="天", current=206.4, target=150)["level"] == "success"
|
||||
|
||||
|
||||
def test_inverse_whitelist_is_explicit_not_direction_based():
|
||||
"""§4.1 逆指标=显式白名单;#230/#23 库内 direction 为 NULL 也不得漏"""
|
||||
assert INVERSE_KPI_IDS == frozenset({504, 511, 230, 23})
|
||||
for kid in (504, 511, 230, 23):
|
||||
assert is_inverse_kpi(kid) is True
|
||||
for kid in (1, 11, 502, 503, 505, 506, 507, 508, 509, 510, 512, 513, 514, 515, 516):
|
||||
assert is_inverse_kpi(kid) is False
|
||||
# 短期借款 #230:unit=元、direction=NULL ⇒ 逆指标(不能因 direction 缺失判成状态类/正向)
|
||||
got = compute_level(kpi_id=230, unit="元", current=211.5, target=211.5)
|
||||
assert got["mode"] == "inverse"
|
||||
assert got["level"] == "success"
|
||||
|
||||
|
||||
# ── §4.2 状态类:按 unit 判,1=绿 / 0=NULL=红,且绝不做除法 ──────────
|
||||
@pytest.mark.parametrize("kpi_id,current,expect", [
|
||||
(512, 1, "success"),
|
||||
(512, 0, "danger"),
|
||||
(512, None, "danger"), # 件 §4.2 逐字:0 / NULL = 未达成 ⇒ 红
|
||||
(513, 1, "success"),
|
||||
(515, 1, "success"),
|
||||
(516, 0, "danger"),
|
||||
])
|
||||
def test_status_class_by_unit(kpi_id, current, expect):
|
||||
unit = STATUS_UNIT # '状态'
|
||||
got = compute_level(kpi_id=kpi_id, unit=unit, current=current, target=1)
|
||||
assert got["mode"] == "status"
|
||||
assert got["level"] == expect
|
||||
assert got["rate"] is None, "状态类不得产出达成率"
|
||||
|
||||
|
||||
def test_status_class_never_divides():
|
||||
"""状态类 + 目标 0/缺失 ⇒ 依然按 1/0 判色,不触发除零"""
|
||||
for target in (None, 0):
|
||||
got = compute_level(kpi_id=512, unit="状态", current=0, target=target)
|
||||
assert got["level"] == "danger" and got["rate"] is None
|
||||
assert is_status_kpi("状态") is True
|
||||
assert is_status_kpi("状态数") is False # 精确匹配,不用 startswith
|
||||
assert is_status_kpi(None) is False
|
||||
|
||||
|
||||
def test_status_not_inferred_from_null_direction():
|
||||
"""`direction IS NULL` 不等于状态类:#230/#23 同为 NULL 但不走状态分支"""
|
||||
for kid, unit in ((230, "元"), (23, "%")):
|
||||
assert compute_level(kpi_id=kid, unit=unit, current=10, target=10)["mode"] == "inverse"
|
||||
for kid in (512, 513, 515, 516):
|
||||
assert compute_level(kpi_id=kid, unit=STATUS_UNIT, current=1, target=1)["mode"] == "status"
|
||||
|
||||
|
||||
# ── §4.3 零点保护 / 空值 ────────────────────────────────────────────
|
||||
@pytest.mark.parametrize("current,target,reason", [
|
||||
(None, 150, "无实际值"),
|
||||
(206.4, None, "目标值缺失或为 0"),
|
||||
(206.4, 0, "目标值缺失或为 0"),
|
||||
(0, 150, "逆指标现值为 0(倒数无定义)"),
|
||||
])
|
||||
def test_zero_guard_returns_info(current, target, reason):
|
||||
got = compute_level(kpi_id=504, unit="天", current=current, target=target)
|
||||
assert got["level"] == "info"
|
||||
assert got["color"] == LEVEL_COLORS["info"]
|
||||
assert got["rate"] is None
|
||||
assert got["reason"] == reason
|
||||
|
||||
|
||||
def test_never_nan_or_inf_or_raise():
|
||||
"""历史教训:`{{ current.cover.toFixed(1) }}` 对 null 无守卫崩卡 —— 后端必须不产出 NaN/Inf"""
|
||||
bad_inputs = [
|
||||
(None, None), (None, 0), ("", ""), ("abc", "def"), (float("nan"), 100),
|
||||
(100, float("nan")), (float("inf"), 100), (100, float("inf")),
|
||||
("206.4", "150"), (False, 1), ([], {}),
|
||||
]
|
||||
for cur, tgt in bad_inputs:
|
||||
got = compute_level(kpi_id=504, unit="天", current=cur, target=tgt)
|
||||
assert got["rate"] is None or got["rate"] == got["rate"]
|
||||
assert got["rate"] not in (float("inf"), float("-inf"))
|
||||
assert got["level"] in LEVEL_COLORS
|
||||
# 字符串数字可按数值处理(Excel/JSON 常见)
|
||||
assert compute_level(kpi_id=1, unit="万元", current="808.80", target="1050")["rate"] == pytest.approx(0.7703, abs=1e-4)
|
||||
|
||||
|
||||
# ── 阈值边界 ────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize("current,target,expect", [
|
||||
(100, 100, "success"), (120, 100, "success"),
|
||||
(80, 100, "warning"), (99.9, 100, "warning"),
|
||||
(79.99, 100, "danger"), (0.01, 100, "danger"),
|
||||
])
|
||||
def test_threshold_boundaries(current, target, expect):
|
||||
assert compute_level(kpi_id=1, unit="万元", current=current, target=target)["level"] == expect
|
||||
|
||||
|
||||
# ── 期间排序 / 取最新(实测区取值口径)─────────────────────────────
|
||||
@pytest.mark.parametrize("a,b", [
|
||||
("2026-08", "2026-H1"), ("2026-Q4", "2026-09"), ("2027", "2026-H2"),
|
||||
("2026-12", "2026-01"), ("2026-Q3", "2026-Q2"),
|
||||
])
|
||||
def test_period_rank_order(a, b):
|
||||
assert period_rank(a) > period_rank(b)
|
||||
|
||||
|
||||
def test_period_rank_unparsable_is_zero():
|
||||
for bad in (None, "", "未知", "abc", "2026-13"):
|
||||
assert period_rank(bad) == 0
|
||||
|
||||
|
||||
class _Row:
|
||||
def __init__(self, period, data_status, actual_value, id):
|
||||
self.period, self.data_status, self.actual_value, self.id = period, data_status, actual_value, id
|
||||
|
||||
|
||||
def test_pick_latest_prefers_newer_period_then_verified():
|
||||
rows = [
|
||||
_Row("2026-01", "verified", 180.87, 1),
|
||||
_Row("2026-08", "error", 14.45, 9),
|
||||
_Row("2026-08", "verified", 14.45, 8),
|
||||
_Row("2026-H1", "verified", 713.27, 10),
|
||||
]
|
||||
got = pick_latest(rows)
|
||||
assert got.period == "2026-08"
|
||||
assert got.data_status == "verified", "同期间内必须取 verified(不被 error 行顶掉)"
|
||||
assert pick_latest([]) is None
|
||||
|
||||
|
||||
# ── 层色规范(§三,固定)─────────────────────────────────────────────
|
||||
def test_layer_colors_fixed():
|
||||
assert LAYER_META["finance"]["color"] == "#F56C6C"
|
||||
assert LAYER_META["customer"]["color"] == "#409EFF"
|
||||
assert LAYER_META["process"]["color"] == "#67C23A"
|
||||
assert LAYER_META["learning"]["color"] == "#E6A23C"
|
||||
# 未知/缺失 dimension_key 不抛错、不丢节点(置灰占位)
|
||||
assert layer_meta(None)["color"] == LEVEL_COLORS["info"]
|
||||
assert layer_meta("mystery")["color"] == LEVEL_COLORS["info"]
|
||||
|
||||
|
||||
def test_level_colors_fixed():
|
||||
assert LEVEL_COLORS["success"] == "#16A34A"
|
||||
assert LEVEL_COLORS["warning"] == "#EAB308"
|
||||
assert LEVEL_COLORS["danger"] == "#DC2626"
|
||||
assert LEVEL_COLORS["info"] == "#94A3B8"
|
||||
@@ -67,6 +67,8 @@ export const mapApi = {
|
||||
delete: (id: number) => api.delete(`/maps/${id}`),
|
||||
batchDelete: (ids: number[]) => api.post('/maps/batch-delete', { ids }),
|
||||
createWithTemplate: (data: any) => api.post('/maps/create-with-template', data),
|
||||
// 战略地图活看板(只读,2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
|
||||
strategyBoard: (id: number, params?: any) => api.get(`/maps/${id}/strategy-board`, { params }),
|
||||
// 战略地图版本管理(2026-09-08 project-20260908-map-versions-page)
|
||||
versions: (mapId: number) => api.get(`/maps/${mapId}/versions`),
|
||||
versionDetail: (mapId: number, vid: number) => api.get(`/maps/${mapId}/versions/${vid}`),
|
||||
|
||||
@@ -9,10 +9,10 @@ interface MenuItem {
|
||||
|
||||
// ── 角色路由映射 ──
|
||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
|
||||
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/strategy-map', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification', '/cashflow-structure', '/treasury-dashboard', '/treasury-payment'],
|
||||
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/strategy-map', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification', '/treasury-payment'],
|
||||
}
|
||||
|
||||
export const ROLE_ACTIONS: Record<string, string[]> = {
|
||||
@@ -26,6 +26,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
// ── GROUP 1: 规划与目标(Plan)──
|
||||
{ path: '/my-dashboard', label: '我的工作台', icon: 'Monitor', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/maps', label: '战略地图', icon: 'DataBoard', roles: ['ceo', 'finance'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/strategy-map', label: '战略地图看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/budget', label: '预算管理', icon: 'Coin', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
{ path: '/alignment', label: '战略执行看板', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🔵 P 规划与目标' },
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ const routes = [
|
||||
{ path: 'learning-dashboard', name: 'LearningDashboard', component: () => import('@/views/LearningDashboard.vue'), meta: { title: '学习成长看板', roles: ['ceo', 'finance', 'it'], zone: 'A' } },
|
||||
{ path: 'maps-review', name: 'MapReviewList', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
|
||||
{ path: 'maps/canvas/:id', name: 'MapCanvas', component: () => import('@/views/MapCanvas.vue'), meta: { title: '战略地图画布', roles: ['ceo', 'finance'], zone: 'B', editable: true } },
|
||||
// /strategy-map: A区(只读活看板 —— 读库渲染,页内零写入口;2026-10-11 finance-20261011-strategy-map-frontend-fullstack)
|
||||
{ path: 'strategy-map', name: 'StrategyMap', component: () => import('@/views/StrategyMap.vue'), meta: { title: '战略地图看板', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
{ path: 'maps/review/:id', name: 'MapReview', component: () => import('@/views/MapReview.vue'), meta: { title: '战略回顾会', roles: ['ceo', 'finance'], zone: 'B' } },
|
||||
{ path: 'my-dashboard', name: 'MyDashboard', component: () => import('@/views/MyDashboard.vue'), meta: { title: '我的工作台', roles: ['ceo', 'finance', 'business', 'it'], zone: 'A' } },
|
||||
{ path: 'knowledge', name: 'CMAKnowledge', component: () => import('@/views/CMAKnowledge.vue'), meta: { title: 'CMA知识库', roles: ['ceo', 'finance', 'business', 'it'], zone: 'B', editable: true } },
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
<template>
|
||||
<div class="strategy-map-page" ref="pageRef">
|
||||
<!-- ── 页头:地图标题 + 版本 + 数据截止期 + 期间切换(只读页,无任何写入口)── -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">
|
||||
<el-icon class="title-icon"><DataBoard /></el-icon>
|
||||
<span>{{ board?.map?.title || '战略地图' }}</span>
|
||||
<el-tag v-if="board?.map?.version" size="small" effect="plain">{{ board.map.version }}</el-tag>
|
||||
<el-tag v-if="board?.map?.status" size="small" type="info" effect="plain">{{ board.map.status }}</el-tag>
|
||||
</h2>
|
||||
<div class="header-right">
|
||||
<span class="asof">
|
||||
<el-icon><Clock /></el-icon>
|
||||
数据截止期:<b>{{ board?.data_asof || '—' }}</b>
|
||||
</span>
|
||||
<el-radio-group v-model="period" size="small" :disabled="!periods.length" @change="load">
|
||||
<el-radio-button v-for="p in periods" :key="p" :value="p">{{ p }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button size="small" :loading="loading" @click="load">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
<el-button size="small" @click="doPrint">
|
||||
<el-icon><Printer /></el-icon>打印 A3
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 口径/统计条 ── -->
|
||||
<div class="meta-bar">
|
||||
<span class="axis-hint">泳道自下而上:学习 → 流程 → 客户 → 财务</span>
|
||||
<span class="legend">
|
||||
<span v-for="lg in legend" :key="lg.level" class="legend-item">
|
||||
<i class="dot" :style="{ background: lg.color }"></i>{{ lg.label }} {{ lg.count }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="stat">
|
||||
节点 <b>{{ stats.nodes }}</b> · 已回填实际值 <b>{{ stats.with_actual }}</b>/{{ stats.nodes }} ·
|
||||
因果链 <b>{{ stats.edges }}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="missingTip"
|
||||
class="caliber-note"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="missingTip"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
class="caliber-note"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="errorMsg"
|
||||
/>
|
||||
|
||||
<!-- ── 看板主体:泳道 + 因果箭头 ── -->
|
||||
<div v-loading="loading" class="board-wrap" ref="boardRef">
|
||||
<svg class="edge-layer" :width="boardSize.w" :height="boardSize.h">
|
||||
<defs>
|
||||
<marker id="sm-arrow" viewBox="0 0 10 10" refX="9" refY="5"
|
||||
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#94A3B8" />
|
||||
</marker>
|
||||
<marker id="sm-arrow-neg" viewBox="0 0 10 10" refX="9" refY="5"
|
||||
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#F56C6C" />
|
||||
</marker>
|
||||
</defs>
|
||||
<path
|
||||
v-for="e in edgePaths"
|
||||
:key="e.id"
|
||||
:d="e.d"
|
||||
fill="none"
|
||||
:stroke="e.negative ? '#F56C6C' : '#94A3B8'"
|
||||
:stroke-width="e.width"
|
||||
:stroke-dasharray="e.negative ? '6,4' : '0'"
|
||||
:opacity="e.opacity"
|
||||
:marker-end="e.negative ? 'url(#sm-arrow-neg)' : 'url(#sm-arrow)'"
|
||||
>
|
||||
<title>{{ e.title }}</title>
|
||||
</path>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-for="layer in displayLayers"
|
||||
:key="layer.key"
|
||||
class="swimlane"
|
||||
:style="{ borderLeftColor: layer.color }"
|
||||
>
|
||||
<div class="lane-head">
|
||||
<el-icon class="lane-icon" :style="{ color: layer.color }">
|
||||
<component :is="layerIcon(layer.key)" />
|
||||
</el-icon>
|
||||
<span class="lane-name" :style="{ color: layer.color }">{{ layer.name }}</span>
|
||||
<span class="lane-count">{{ layer.nodes.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="lane-nodes">
|
||||
<div
|
||||
v-for="n in layer.nodes"
|
||||
:key="n.id"
|
||||
:ref="(el) => setNodeRef(n.id, el)"
|
||||
class="node-card"
|
||||
:class="'lvl-' + n.level"
|
||||
:style="{ borderTopColor: n.color }"
|
||||
>
|
||||
<div class="card-head">
|
||||
<span class="card-name" :title="n.name">{{ n.name }}</span>
|
||||
<el-tag size="small" effect="plain" class="unit-tag">{{ n.unit || '—' }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="card-value">
|
||||
<span class="cur" :class="'cur-' + n.level">
|
||||
{{ n.current === null || n.current === undefined ? '待回填' : fmt(n.current) }}
|
||||
</span>
|
||||
<span class="arrow-mark">
|
||||
<el-icon><Right /></el-icon>
|
||||
</span>
|
||||
<span class="tgt">{{ n.target === null || n.target === undefined ? '—' : fmt(n.target) }}</span>
|
||||
<span class="pct" :style="{ color: n.color }">
|
||||
{{ n.rate === null || n.rate === undefined ? (n.mode === 'status' ? '状态' : '—') : pct(n.rate) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
<div class="bar-in" :style="{ width: barWidth(n), background: n.color }"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-foot">
|
||||
<span class="foot-left">
|
||||
<i class="dot" :style="{ background: n.color }"></i>{{ levelLabel(n.level) }}
|
||||
<span v-if="n.mode === 'inverse'" class="tag-plain">逆</span>
|
||||
<span v-if="n.mode === 'status'" class="tag-plain">状态</span>
|
||||
</span>
|
||||
<span class="foot-right" :title="ownerTip(n)">{{ n.owner || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="noteOf(n)" class="card-note" :title="noteOf(n)">{{ noteOf(n) }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!layer.nodes.length" class="lane-empty">本层无节点</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && !stats.nodes" description="该地图暂无节点(map_objectives 为空)" />
|
||||
</div>
|
||||
|
||||
<div class="print-foot">
|
||||
口径:达成率 = 实际值 / 目标值(逆指标取倒数)· 绿 ≥100% / 黄 ≥80% / 红 <80% / 灰 = 无实际值 ·
|
||||
实际值取「实测区」(排除预估/预测行)最新期 · 数据来源:CMA 生产库(只读)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
DataBoard, Clock, Refresh, Printer, Right,
|
||||
Money, User, Setting, Reading, TrendCharts,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { mapApi } from '../api/index'
|
||||
|
||||
// ── 状态 ──
|
||||
const boardRef = ref<HTMLElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const board = ref<any>(null)
|
||||
const period = ref<string>('')
|
||||
const periods = ref<string[]>([])
|
||||
const boardSize = ref({ w: 0, h: 0 })
|
||||
const edgePaths = ref<any[]>([])
|
||||
|
||||
const MAP_ID = 65 // 陕西酣客三年战略规划(e1);map 列表为空时回退到「最新非归档地图」
|
||||
|
||||
const displayLayers = computed<any[]>(() => {
|
||||
const layers = (board.value?.layers || []).slice()
|
||||
// 后端已按 学习→流程→客户→财务 返回(自下而上);页面自上而下渲染需反转
|
||||
const known = ['learning', 'process', 'customer', 'finance']
|
||||
layers.sort((a, b) => known.indexOf(b.key) - known.indexOf(a.key))
|
||||
return layers
|
||||
})
|
||||
|
||||
const stats = computed(() => board.value?.stats || {
|
||||
nodes: 0, with_actual: 0, missing_actual: 0, edges: 0,
|
||||
by_level: { success: 0, warning: 0, danger: 0, info: 0 },
|
||||
})
|
||||
|
||||
const legend = computed(() => {
|
||||
const bl = stats.value.by_level || {}
|
||||
return [
|
||||
{ level: 'success', label: '达成', color: '#16A34A', count: bl.success || 0 },
|
||||
{ level: 'warning', label: '接近', color: '#EAB308', count: bl.warning || 0 },
|
||||
{ level: 'danger', label: '未达成', color: '#DC2626', count: bl.danger || 0 },
|
||||
{ level: 'info', label: '待回填', color: '#94A3B8', count: bl.info || 0 },
|
||||
]
|
||||
})
|
||||
|
||||
const missingTip = computed(() => {
|
||||
const s = stats.value
|
||||
if (!s.nodes || !s.missing_actual) return ''
|
||||
return `本图 ${s.nodes} 个节点中 ${s.missing_actual} 个尚无 kpi_values 实际值,按口径置灰(无实际值 ⇒ 灰),`
|
||||
+ '待数据侧回填后自动上色;另有状态类指标(单位=状态)按 1=达成 / 0 或未填报=未达成 判色,不参与达成率。'
|
||||
})
|
||||
|
||||
// ── 取数(只读 GET)──
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const params: any = {}
|
||||
if (period.value) params.period = period.value
|
||||
const r: any = await mapApi.strategyBoard(MAP_ID, params)
|
||||
board.value = r
|
||||
if (!periods.value.length) periods.value = r.periods || []
|
||||
if (r.period) period.value = r.period
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status
|
||||
if (status === 404) {
|
||||
board.value = null
|
||||
await loadFallbackMap()
|
||||
} else {
|
||||
board.value = null
|
||||
errorMsg.value = `战略地图数据加载失败${status ? '(HTTP ' + status + ')' : ''}:${e?.message || e}`
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
drawEdges()
|
||||
}
|
||||
}
|
||||
|
||||
/** 未命中 map 65 时:取本账套第一张非归档地图(只读,不写库) */
|
||||
async function loadFallbackMap() {
|
||||
try {
|
||||
const maps: any = await mapApi.list()
|
||||
const list = (maps?.data || maps || []).filter((m: any) => m.status !== 'archived')
|
||||
const target = list.find((m: any) => m.id === MAP_ID) || list[0]
|
||||
if (!target) {
|
||||
errorMsg.value = '本账套暂无战略地图'
|
||||
return
|
||||
}
|
||||
const params: any = period.value ? { period: period.value } : {}
|
||||
const r: any = await mapApi.strategyBoard(target.id, params)
|
||||
board.value = r
|
||||
periods.value = r.periods || []
|
||||
if (r.period) period.value = r.period
|
||||
} catch (e: any) {
|
||||
errorMsg.value = `战略地图数据加载失败:${e?.message || e}`
|
||||
}
|
||||
}
|
||||
|
||||
// ── 节点 ref / 因果箭头 ──
|
||||
const nodeEls = new Map<number, HTMLElement>()
|
||||
|
||||
function setNodeRef(id: number, el: any) {
|
||||
if (el) nodeEls.set(id, el as HTMLElement)
|
||||
else nodeEls.delete(id)
|
||||
}
|
||||
|
||||
function anchor(el: HTMLElement, up: boolean) {
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: r.left + r.width / 2, y: up ? r.top : r.bottom }
|
||||
}
|
||||
|
||||
function drawEdges() {
|
||||
const wrap = boardRef.value
|
||||
if (!wrap) return
|
||||
const wrapRect = wrap.getBoundingClientRect()
|
||||
const edges = board.value?.edges || []
|
||||
boardSize.value = { w: wrap.clientWidth, h: wrap.clientHeight }
|
||||
const paths: any[] = []
|
||||
for (const e of edges) {
|
||||
const s = nodeEls.get(e.source_node_id)
|
||||
const t = nodeEls.get(e.target_node_id)
|
||||
if (!s || !t) continue
|
||||
const sr = s.getBoundingClientRect()
|
||||
const tr = t.getBoundingClientRect()
|
||||
const start = anchor(s, tr.top < sr.top)
|
||||
const end = anchor(t, tr.top < sr.top ? false : true)
|
||||
const x1 = start.x - wrapRect.left
|
||||
const y1 = start.y - wrapRect.top
|
||||
const x2 = end.x - wrapRect.left
|
||||
const y2 = end.y - wrapRect.top
|
||||
const dy = (y2 - y1) / 2
|
||||
paths.push({
|
||||
id: e.id,
|
||||
d: `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`,
|
||||
negative: e.direction === 'negative',
|
||||
width: Math.max(1, Math.round((e.strength || 0.5) * 3)),
|
||||
opacity: 0.35 + (e.strength || 0.5) * 0.5,
|
||||
title: `${e.formula || ''}|强度 ${e.strength}|滞后 ${e.lag_months} 月|${e.direction === 'negative' ? '负向' : '正向'}`,
|
||||
})
|
||||
}
|
||||
edgePaths.value = paths
|
||||
}
|
||||
|
||||
let resizeTimer: any = null
|
||||
function onResize() {
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(() => drawEdges(), 150)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
})
|
||||
|
||||
// ── 展示辅助 ──
|
||||
function fmt(v: any) {
|
||||
const n = Number(v)
|
||||
if (!isFinite(n)) return '—'
|
||||
const abs = Math.abs(n)
|
||||
if (abs >= 10000) return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
if (abs >= 100) return n.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
|
||||
return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
}
|
||||
function pct(r: any) {
|
||||
const n = Number(r)
|
||||
return isFinite(n) ? (n * 100).toFixed(0) + '%' : '—'
|
||||
}
|
||||
function barWidth(n: any) {
|
||||
if (n.mode === 'status') return n.level === 'success' ? '100%' : '0%'
|
||||
const r = Number(n.rate)
|
||||
if (!isFinite(r)) return '0%'
|
||||
return Math.max(0, Math.min(1, r)) * 100 + '%'
|
||||
}
|
||||
function levelLabel(lv: string) {
|
||||
return { success: '达成', warning: '接近', danger: '未达成', info: '待回填' }[lv] || lv
|
||||
}
|
||||
function noteOf(n: any) {
|
||||
if (n.reason) return n.reason
|
||||
if (n.binding_source === 'none') return '未绑定 KPI'
|
||||
if (n.current_period) return `实际值期间 ${n.current_period}`
|
||||
return ''
|
||||
}
|
||||
function ownerTip(n: any) {
|
||||
return `责任人:${n.owner || '—'}|KPI:${n.kpi_code || '—'}|绑定来源:${n.binding_source || '—'}`
|
||||
}
|
||||
function layerIcon(key: string) {
|
||||
return { finance: Money, customer: User, process: Setting, learning: Reading }[key] || TrendCharts
|
||||
}
|
||||
function doPrint() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.strategy-map-page { padding: 16px; background: #f5f7fa; min-height: 100%; }
|
||||
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
|
||||
.page-title { display: flex; align-items: center; gap: 8px; font-size: 19px; font-weight: 600; color: #1f2d3d; margin: 0; }
|
||||
.title-icon { color: #409eff; }
|
||||
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.asof { font-size: 13px; color: #606266; display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
.meta-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 12.5px; color: #606266; margin-bottom: 8px; }
|
||||
.axis-hint { color: #909399; }
|
||||
.legend { display: inline-flex; gap: 12px; }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
|
||||
.caliber-note { margin-bottom: 8px; }
|
||||
|
||||
.board-wrap { position: relative; background: #fff; border-radius: 6px; padding: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.edge-layer { position: absolute; left: 0; top: 0; pointer-events: none; z-index: 1; }
|
||||
|
||||
.swimlane { position: relative; z-index: 2; border-left: 4px solid #dcdfe6; background: #fbfcfe; border-radius: 4px; padding: 8px 10px; margin-bottom: 10px; }
|
||||
.lane-head { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13.5px; margin-bottom: 8px; }
|
||||
.lane-icon { font-size: 16px; }
|
||||
.lane-count { font-weight: 400; color: #909399; font-size: 12px; }
|
||||
.lane-nodes { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.lane-empty { color: #c0c4cc; font-size: 12px; }
|
||||
|
||||
.node-card { width: 232px; background: #fff; border: 1px solid #ebeef5; border-top: 3px solid #94a3b8; border-radius: 5px; padding: 8px 10px 6px; box-shadow: 0 1px 3px rgba(0,0,0,.05); }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.card-name { font-size: 13.5px; font-weight: 600; color: #1f2d3d; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.unit-tag { flex: none; }
|
||||
.card-value { display: flex; align-items: baseline; gap: 4px; margin: 6px 0 5px; }
|
||||
.cur { font-size: 16px; font-weight: 700; }
|
||||
.cur-success { color: #16A34A; }
|
||||
.cur-warning { color: #EAB308; }
|
||||
.cur-danger { color: #DC2626; }
|
||||
.cur-info { color: #94A3B8; }
|
||||
.arrow-mark { color: #c0c4cc; display: inline-flex; align-items: center; }
|
||||
.tgt { font-size: 14px; color: #606266; }
|
||||
.pct { margin-left: auto; font-size: 12.5px; font-weight: 600; }
|
||||
.bar { height: 6px; background: #f0f2f5; border-radius: 3px; overflow: hidden; }
|
||||
.bar-in { height: 100%; border-radius: 3px; transition: width .3s; }
|
||||
.card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; font-size: 11.5px; color: #606266; }
|
||||
.foot-left { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.foot-right { color: #909399; max-width: 108px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tag-plain { border: 1px solid #dcdfe6; border-radius: 3px; padding: 0 3px; color: #909399; font-size: 10.5px; }
|
||||
.card-note { margin-top: 4px; font-size: 11px; color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.print-foot { margin-top: 10px; font-size: 11.5px; color: #909399; line-height: 1.6; }
|
||||
|
||||
@media print {
|
||||
@page { size: A3 landscape; margin: 8mm; }
|
||||
.strategy-map-page { padding: 0; background: #fff; }
|
||||
.header-right .el-button { display: none; }
|
||||
.board-wrap { box-shadow: none; padding: 0; }
|
||||
.node-card { break-inside: avoid; }
|
||||
.swimlane { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user