wip(strategy-map): V1+V3 合版 + v2/v3 口径(完备度/C1 hold/57 归档组)
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""战略地图 · 数据完备度 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。禁用组合(19/19、83.9%)在本模块无生成路径。
|
||||
"""
|
||||
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挂接(23-KPI 挂接 或 map_objectives.kpis 非空)----
|
||||
# 附注与主数同属地图 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,
|
||||
}
|
||||
+24
-1
@@ -16,6 +16,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 +83,18 @@ 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)):
|
||||
"""战略地图摘要条 · 收入口径状态(真数据铁律:未闭合不出单一汇总数)。
|
||||
|
||||
2026-10-18 改造单 v2 §4:A1 收入口径未闭合前,前端只显示「口径待定」+
|
||||
分业务位(酒类代销 / IT自营,amount=None ⇒ 显示「—/无数据」);
|
||||
后端不得擅自选总额/净额,也不得把两块合并成一个「营业收入」。
|
||||
"""
|
||||
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导致加载报错)
|
||||
@@ -1026,7 +1047,9 @@ 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%)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""口径判据数据包(战略地图数据完备度等,只读常量)。"""
|
||||
@@ -0,0 +1,72 @@
|
||||
"""战略地图数据完备度 · 口径定义(唯一真源引用,勿改判据/分母)
|
||||
|
||||
口径 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,63 @@
|
||||
"""战略地图 · 收入口径(revenue caliber)状态 —— 单一事实源
|
||||
|
||||
口径来源:改造单 `project-20261018-cma-strategy-map-ux-revamp-fullstack` 增量 v2 §4。
|
||||
截至本文件落地(2026-10-18T23:00),A1 收入口径**尚未闭合**:
|
||||
- 业务称谓「代理」≠ 会计判定(总额法 total / 净额法 net / 混合 mixed 未裁);
|
||||
- 财务净额件与项目侧「三问」总额判定并存,正由 A1 补充单裁决。
|
||||
|
||||
因此本模块只声明**状态**与**业务线维度**,不存任何金额:
|
||||
- 接口照常返回 `revenue_caliber` 与分业务位(amount=None + status=pending);
|
||||
- 前端不得写死任一方法,不得把两块合并成一个「营业收入」;
|
||||
- 口径闭合后只改本文件(status/caliber/decided_by/closed_at),并回填金额取数逻辑,
|
||||
不需要改前端。
|
||||
|
||||
铁律:未闭合前,页面与接口都**不得**输出单一汇总收入数。
|
||||
"""
|
||||
|
||||
# 口径状态:unclosed / closed
|
||||
STATUS = "unclosed"
|
||||
CALIBER = None # closed 时取 "total" / "net" / "mixed"
|
||||
CALIBER_LABEL = "口径待定"
|
||||
DECIDED_BY = None # closed 时填裁决单/ASK 编号
|
||||
CLOSED_AT = None # ISO 时间
|
||||
REASON = (
|
||||
"A1 收入口径尚未闭合(截至 2026-10-18T23:00):业务称谓「代理」≠ 会计判定,"
|
||||
"总额法/净额法/混合法并存,待 A1 补充单裁决"
|
||||
)
|
||||
|
||||
# 分业务(不得合并;amount 由 closed 后的取数逻辑回填)
|
||||
BUSINESS_LINES = [
|
||||
{"key": "liquor_agency", "label": "酒类代销"},
|
||||
{"key": "it_self", "label": "IT自营"},
|
||||
]
|
||||
|
||||
|
||||
def snapshot(entity_id: int) -> dict:
|
||||
"""返回对外口径快照(无金额;未闭合时 amount 一律 None)。"""
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"status": STATUS,
|
||||
"caliber": CALIBER,
|
||||
"caliber_label": CALIBER_LABEL,
|
||||
"caliber_options": [
|
||||
{"key": "total", "label": "总额法"},
|
||||
{"key": "net", "label": "净额法"},
|
||||
{"key": "mixed", "label": "混合法"},
|
||||
],
|
||||
"decided_by": DECIDED_BY,
|
||||
"closed_at": CLOSED_AT,
|
||||
"reason": REASON,
|
||||
"businesses": [
|
||||
{
|
||||
"key": b["key"],
|
||||
"label": b["label"],
|
||||
"amount": None,
|
||||
"unit": "万元",
|
||||
"status": "pending",
|
||||
"missing_reason": "收入口径未闭合" if STATUS != "closed" else None,
|
||||
}
|
||||
for b in BUSINESS_LINES
|
||||
],
|
||||
"allow_single_total": STATUS == "closed",
|
||||
"note": "未闭合前不显示单一汇总数;分业务分列,不合并为「营业收入」",
|
||||
}
|
||||
+2
-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
|
||||
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
|
||||
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
|
||||
@@ -56,6 +56,7 @@ app.include_router(action_plans.router)
|
||||
app.include_router(alignment.router)
|
||||
app.include_router(org.router)
|
||||
app.include_router(objectives.router)
|
||||
app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
|
||||
app.include_router(versions.router)
|
||||
app.include_router(budget.router)
|
||||
app.include_router(value_sources.router)
|
||||
|
||||
Vendored
+5
@@ -80,7 +80,12 @@ 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']
|
||||
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,129 @@
|
||||
<template>
|
||||
<section class="map-summary" aria-label="战略地图摘要">
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">营业收入</div>
|
||||
<div class="sum-value mono" :class="{ muted: revenue == null }">
|
||||
<template v-if="revenue != null">{{ fmtWan(revenue) }}<span class="sum-unit">万元</span></template>
|
||||
<template v-else>无数据</template>
|
||||
</div>
|
||||
<div class="sum-sub">{{ period ? period + ' 实际值' : '财务分析接口未返回期间' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">毛利率</div>
|
||||
<div class="sum-value mono" :class="{ muted: grossMargin == null }">
|
||||
<template v-if="grossMargin != null">{{ grossMargin.toFixed(1) }}<span class="sum-unit">%</span></template>
|
||||
<template v-else>无数据</template>
|
||||
</div>
|
||||
<div class="sum-sub">{{ period ? period + ' 实际值' : '未取到财务分析数据' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="sum-card">
|
||||
<div class="sum-label">战略目标达成率</div>
|
||||
<div class="sum-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
|
||||
<template v-if="achievement != null">{{ Math.round(achievement) }}<span class="sum-unit">%</span></template>
|
||||
<template v-else>无数据</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-label">风险目标</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>无数据</template>
|
||||
</div>
|
||||
<div class="sum-sub">
|
||||
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 共 {{ summary.total }} 个目标</template>
|
||||
<template v-else>战略回顾数据未加载</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
finance: { total_sales: number | null; gross_profit_rate: number | null; period?: string } | null
|
||||
achievement: number | null
|
||||
achievementCount: number
|
||||
summary: { total: number; green: number; yellow: number; red: number; health_score?: number } | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const revenue = computed<number | null>(() => {
|
||||
const v = props.finance?.total_sales
|
||||
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
|
||||
})
|
||||
const grossMargin = computed<number | null>(() => {
|
||||
const v = props.finance?.gross_profit_rate
|
||||
return v == null || Number.isNaN(Number(v)) ? null : Number(v)
|
||||
})
|
||||
const period = computed(() => props.finance?.period || '')
|
||||
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'
|
||||
})
|
||||
|
||||
/** 金额统一折算为万元(保留 1 位小数),不做任何兜底造数 */
|
||||
function fmtWan(v: number): string {
|
||||
return (v / 10000).toLocaleString('zh-CN', { maximumFractionDigits: 1, 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-label {
|
||||
font-size: 12px;
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.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; color: var(--bh-text-secondary, #64748B); }
|
||||
.sum-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--bh-text-muted, #94A3B8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.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)); }
|
||||
}
|
||||
</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,51 +25,100 @@
|
||||
@toggle-chain-mode="toggleChainMode"
|
||||
/>
|
||||
|
||||
<MapSummaryBar
|
||||
:finance="financeSummary"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<!-- 因果链查看模式提示 -->
|
||||
<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"
|
||||
@@ -106,6 +155,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"
|
||||
@@ -182,17 +269,32 @@
|
||||
<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;不使用 ElMessageBox 确认) -->
|
||||
<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;">
|
||||
<KnowledgePanel related-page="/maps" />
|
||||
<KnowledgePanel related-page="/maps/canvas" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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"
|
||||
@@ -200,6 +302,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 MapCanvasDialogs from "../components/map-canvas/MapCanvasDialogs.vue"
|
||||
@@ -238,6 +345,46 @@ const kpiNameMap = reactive<Record<string, string>>({})
|
||||
const dimensions = reactive<any[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// ── 仪表盘视图状态(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 financeSummary = 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)
|
||||
@@ -273,6 +420,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()
|
||||
@@ -321,14 +469,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)
|
||||
)
|
||||
|
||||
// 弹窗
|
||||
const showNodeDialog = ref(false)
|
||||
const editingNodeData = ref<any>(null)
|
||||
@@ -357,6 +598,12 @@ function onSaveDialog(data: any) {
|
||||
const showVersions = ref(false)
|
||||
const versions = ref<any[]>([])
|
||||
|
||||
// 数据完备度弹窗 + 版本回滚确认(MyDialog,替代 ElMessageBox.confirm)
|
||||
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)
|
||||
@@ -496,6 +743,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) => {
|
||||
@@ -577,12 +826,130 @@ 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 el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-node-key]') as HTMLElement | null
|
||||
const key = el?.getAttribute('data-node-key') || null
|
||||
dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -602,6 +969,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) => {
|
||||
@@ -770,6 +1138,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
|
||||
@@ -792,6 +1161,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) => {
|
||||
@@ -809,7 +1179,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 = ''
|
||||
@@ -848,6 +1219,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
|
||||
@@ -979,6 +1351,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("节点已删除")
|
||||
@@ -1002,6 +1375,7 @@ function onAddKr(dimKey: string, oi: number, obj: any) {
|
||||
ElMessage.warning('每个目标最多5个关键结果')
|
||||
return
|
||||
}
|
||||
pushUndo()
|
||||
target.krs.push({
|
||||
name: "",
|
||||
target_value: "",
|
||||
@@ -1055,6 +1429,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 {
|
||||
@@ -1321,13 +1696,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1336,46 +1720,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'
|
||||
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
|
||||
}
|
||||
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) {
|
||||
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: 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 }
|
||||
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 */ }
|
||||
}
|
||||
@@ -1432,6 +1824,25 @@ function goAlignment() {
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
function goKpiLibrary() {
|
||||
window.location.href = '/kpis'
|
||||
}
|
||||
|
||||
/** KPI 摘要条(营收/毛利率):来源 /dashboard/finance-analysis,缺失即 null(不造数) */
|
||||
async function loadFinanceSummary() {
|
||||
try {
|
||||
const r: any = await api.get('/dashboard/finance-analysis')
|
||||
const s = r?.summary || {}
|
||||
financeSummary.value = {
|
||||
total_sales: s.total_sales ?? null,
|
||||
gross_profit_rate: s.gross_profit_rate ?? null,
|
||||
period: r?.period || '',
|
||||
}
|
||||
} catch {
|
||||
financeSummary.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出画布为PNG图片(transform缩放下截图不完整:临时回100%导出) */
|
||||
async function exportImage() {
|
||||
const el = document.querySelector('.canvas-scroll-wrap') as HTMLElement
|
||||
@@ -1505,6 +1916,7 @@ async function applyAllCausality() {
|
||||
|
||||
// 滚动触发连线重算(rAF节流,避免滚动/拖拽时卡顿)
|
||||
let scrollRaf = 0
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
function onCanvasScroll() {
|
||||
if (scrollRaf) return
|
||||
scrollRaf = requestAnimationFrame(() => {
|
||||
@@ -1517,6 +1929,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()
|
||||
@@ -1532,6 +1950,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) {
|
||||
@@ -1553,12 +1972,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()
|
||||
})
|
||||
@@ -1581,6 +2012,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;
|
||||
@@ -1661,7 +2094,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