- MapCanvas: 层聚焦与回全景联动;右栏停用改自绘可拖详情浮层;目标树默认收起+localStorage 持久化;底部跨层关系摘要条;聚焦态只绘聚焦层同层连线 - StrategyLayer: 层标题可点击聚焦并标「聚焦中」;非聚焦层压缩为 ≤48px 单行摘要(层名—达成率/目标数/mini 色条) - MapSummaryBar: 汇总条压缩 ≤44px、5 项同排;窄屏无横向溢出 - MapEditBar: 新增「全景 / L1–L4」聚焦切换 chip(内联 SVG,无 emoji) - 只读渲染层改动:不写 canvas_data / map_connections,无新增写链路调用
This commit is contained in:
@@ -90,9 +90,32 @@
|
||||
回全景
|
||||
</el-button>
|
||||
|
||||
<el-button size="small" text @click="$emit('toggle-tree')">
|
||||
<el-button size="small" text @click="$emit('toggle-tree')" :title="treeOpen ? '收起左侧目标树(画布全宽)' : '展开左侧目标树'">
|
||||
{{ treeOpen ? '收起目标树' : '展开目标树' }}
|
||||
</el-button>
|
||||
|
||||
<!-- C 方案:聚焦开关(全景 = 默认四层等宽) -->
|
||||
<div class="eb-chips eb-focus" role="group" aria-label="聚焦层">
|
||||
<button
|
||||
type="button"
|
||||
class="eb-chip"
|
||||
:class="{ on: !focusLayer }"
|
||||
title="四层全景(默认)"
|
||||
@click="$emit('focus-layer', null)"
|
||||
>全景</button>
|
||||
<button
|
||||
v-for="cfg in layerConfigs"
|
||||
:key="'focus-' + cfg.key"
|
||||
type="button"
|
||||
class="eb-chip"
|
||||
:class="{ on: focusLayer === cfg.key }"
|
||||
:title="focusLayer === cfg.key ? '再次点击退出聚焦' : '聚焦' + cfg.label"
|
||||
@click="$emit('focus-layer', focusLayer === cfg.key ? null : cfg.key)"
|
||||
>
|
||||
<span class="layer-dot" :style="{ background: cfg.color }"></span>
|
||||
{{ cfg.short }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -105,6 +128,8 @@ defineProps<{
|
||||
layerConfigs: { key: string; label: string; short: string; color: string }[]
|
||||
canUndo: boolean
|
||||
treeOpen: boolean
|
||||
/** C 方案聚焦层:null = 四层全景 */
|
||||
focusLayer?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -117,6 +142,7 @@ const emit = defineEmits<{
|
||||
(e: 'fit'): void
|
||||
(e: 'toggle-tree'): void
|
||||
(e: 'completeness'): void
|
||||
(e: 'focus-layer', key: string | null): void
|
||||
}>()
|
||||
|
||||
const filterOptions: { value: 'all' | 'risk' | 'nodata'; label: string }[] = [
|
||||
|
||||
@@ -1,73 +1,132 @@
|
||||
<template>
|
||||
<section class="map-summary" aria-label="战略地图摘要">
|
||||
<!-- C 方案(2026-10-23):汇总条压成单行(≤44px),5 项指标同排;明细收进「明细」展开位 -->
|
||||
<div class="ms-row">
|
||||
<!-- ① 营业收入:分业务线全额售价(总额法)· 禁净额 / 禁合并 -->
|
||||
<div class="sum-card sum-card--wide">
|
||||
<div class="sum-head">
|
||||
<span class="sum-label">营业收入</span>
|
||||
<span v-if="revenue && revenue.caliber_label" class="sum-badge calm">{{ revenue.caliber_label }}</span>
|
||||
<div class="ms-metric ms-metric--rev">
|
||||
<span class="ms-label">营业收入</span>
|
||||
<span class="ms-value mono" :class="{ muted: revenueTotal == null }">
|
||||
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="ms-unit">万元</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
<span v-if="revenue && revenue.caliber_label" class="ms-badge calm">{{ revenue.caliber_label }}</span>
|
||||
</div>
|
||||
|
||||
<span class="ms-sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- ② 毛利率 -->
|
||||
<div class="ms-metric">
|
||||
<span class="ms-label">毛利率</span>
|
||||
<span class="ms-value mono" :class="{ muted: gmValue == null }">
|
||||
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="ms-unit">%</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
<span v-if="grossMargin && grossMargin.data_tag" class="ms-badge warn">{{ grossMargin.data_tag }}</span>
|
||||
</div>
|
||||
|
||||
<span class="ms-sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- ③ 战略目标达成率 -->
|
||||
<div class="ms-metric">
|
||||
<span class="ms-label">战略目标达成率</span>
|
||||
<span class="ms-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
|
||||
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="ms-unit">%</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="ms-sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- ④ 风险目标数 -->
|
||||
<div class="ms-metric">
|
||||
<span class="ms-label">风险目标</span>
|
||||
<span class="ms-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
|
||||
<template v-if="riskCount != null">{{ riskCount }}</template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="ms-sep" aria-hidden="true"></span>
|
||||
|
||||
<!-- ⑤ 目标·连线(当前画布规模) -->
|
||||
<div class="ms-metric ms-metric--scale">
|
||||
<span class="ms-label">目标·连线</span>
|
||||
<span class="ms-value mono">
|
||||
<template v-if="objectivesTotal != null">{{ objectivesTotal }}</template>
|
||||
<template v-else>—</template>
|
||||
<i class="ms-unit">个目标</i>
|
||||
<span class="ms-div">/</span>
|
||||
<template v-if="connectionsTotal != null">{{ connectionsTotal }}</template>
|
||||
<template v-else>—</template>
|
||||
<i class="ms-unit">条连线</i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ms-detail-btn"
|
||||
:aria-expanded="detailOpen ? 'true' : 'false'"
|
||||
@click="detailOpen = !detailOpen"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path :d="detailOpen ? 'M6 15l6-6 6 6' : 'M6 9l6 6 6-6'" />
|
||||
</svg>
|
||||
{{ detailOpen ? '收起' : '明细' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 明细(默认收起;展开后保留分线金额 / 口径 / 管理指标全部信息,不丢信息) -->
|
||||
<div v-if="detailOpen" class="ms-detail">
|
||||
<div class="ms-card">
|
||||
<div class="ms-detail-head">
|
||||
<span class="ms-label">营业收入 · 分业务线</span>
|
||||
<span v-if="revenue && revenue.caliber_label" class="ms-badge calm">{{ revenue.caliber_label }}</span>
|
||||
</div>
|
||||
<div v-if="revenueLines.length" class="rev-lines">
|
||||
<div v-for="ln in revenueLines" :key="ln.key" class="rev-line">
|
||||
<span class="rev-line-label">{{ ln.label }}</span>
|
||||
<span class="rev-line-value mono" :class="{ muted: ln.amount == null }">
|
||||
<template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="sum-unit">万元</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
<template v-if="ln.amount != null">{{ fmtNum(ln.amount) }}<i class="ms-unit">万元</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="rev-line rev-line--total">
|
||||
<span class="rev-line-label">{{ revenueTotalLabel }}</span>
|
||||
<span class="rev-line-value mono">
|
||||
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="sum-unit">万元</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
<template v-if="revenueTotal != null">{{ fmtNum(revenueTotal) }}<i class="ms-unit">万元</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="sum-value muted">—<i class="sum-unit">无数据</i></div>
|
||||
<div class="sum-sub">{{ revenuePeriod }}</div>
|
||||
<div v-else class="ms-value muted">—<i class="ms-unit">无数据</i></div>
|
||||
<div class="ms-sub">{{ revenuePeriod }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ② 毛利率:新式口径(折扣后净进价),历史缺票期带标识 -->
|
||||
<div class="sum-card">
|
||||
<div class="sum-head">
|
||||
<span class="sum-label">毛利率</span>
|
||||
<span v-if="grossMargin && grossMargin.data_tag" class="sum-badge warn">{{ grossMargin.data_tag }}</span>
|
||||
<div class="ms-card">
|
||||
<div class="ms-detail-head"><span class="ms-label">毛利率口径</span></div>
|
||||
<div class="ms-value mono" :class="{ muted: gmValue == null }">
|
||||
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="ms-unit">%</i></template>
|
||||
<template v-else>—<i class="ms-unit">无数据</i></template>
|
||||
</div>
|
||||
<div class="sum-value mono" :class="{ muted: gmValue == null }">
|
||||
<template v-if="gmValue != null">{{ gmValue.toFixed(2) }}<i class="sum-unit">%</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</div>
|
||||
<div class="sum-sub">{{ gmSub }}</div>
|
||||
<div class="ms-sub">{{ gmSub }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ 战略目标达成率 -->
|
||||
<div class="sum-card">
|
||||
<div class="sum-head"><span class="sum-label">战略目标达成率</span></div>
|
||||
<div class="sum-value mono" :class="achievement == null ? 'muted' : 'lv-' + achievementLevel">
|
||||
<template v-if="achievement != null">{{ Math.round(achievement) }}<i class="sum-unit">%</i></template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</div>
|
||||
<div class="sum-sub">
|
||||
<div class="ms-card">
|
||||
<div class="ms-detail-head"><span class="ms-label">达成率 / 风险分布</span></div>
|
||||
<div class="ms-sub">
|
||||
<template v-if="achievement != null">{{ achievementCount }} 个目标有实测值 · 其余无数据</template>
|
||||
<template v-else>所有目标均无可用 KPI 实测值</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ④ 风险目标数 -->
|
||||
<div class="sum-card">
|
||||
<div class="sum-head"><span class="sum-label">风险目标</span></div>
|
||||
<div class="sum-value mono" :class="riskCount == null ? 'muted' : (riskCount > 0 ? 'lv-red' : 'lv-green')">
|
||||
<template v-if="riskCount != null">{{ riskCount }}</template>
|
||||
<template v-else>—<i class="sum-unit">无数据</i></template>
|
||||
</div>
|
||||
<div class="sum-sub">
|
||||
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 共 {{ summary.total }} 个目标</template>
|
||||
<div class="ms-sub">
|
||||
<template v-if="summary">达标 {{ summary.green }} · 关注 {{ summary.yellow }} · 风险 {{ summary.red }} · 共 {{ summary.total }} 个目标</template>
|
||||
<template v-else>战略回顾数据未加载</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⑤ 口径变更标识位 + 管理指标(非会计科目,单列、不占主位) -->
|
||||
<div class="sum-foot">
|
||||
<span v-if="switchInfo && switchInfo.has_switch" class="sum-badge warn" :title="switchInfo.note || ''">
|
||||
<div class="ms-card">
|
||||
<div class="ms-detail-head"><span class="ms-label">口径变更 / 管理指标</span></div>
|
||||
<div class="ms-foot">
|
||||
<span v-if="switchInfo && switchInfo.has_switch" class="ms-badge warn" :title="switchInfo.note || ''">
|
||||
{{ switchInfo.label }}<template v-if="switchInfo.effective_date"> · 自 {{ switchInfo.effective_date }}</template>
|
||||
<em v-if="switchInfo.comparability"> · {{ switchInfo.comparability }}</em>
|
||||
</span>
|
||||
@@ -76,14 +135,16 @@
|
||||
<span class="mgmt-value mono">{{ m.value == null ? '—' : m.value.toFixed(2) + '%' }}</span>
|
||||
<span class="mgmt-tag">{{ m.tag }}</span>
|
||||
<span v-if="m.abnormal" class="mgmt-abnormal">异常·须核查</span>
|
||||
<span class="mgmt-caliber">{{ fmtNum(m.numerator) }} ÷ {{ fmtNum(m.denominator) }}<template v-if="m.period"> · {{ m.period }}</template></span>
|
||||
</span>
|
||||
<span v-if="!(switchInfo && switchInfo.has_switch) && management.length === 0" class="ms-sub">无口径变更与管理指标</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type RevLine = { key: string; label: string; amount: number | null; unit?: string; missing_reason?: string | null }
|
||||
|
||||
@@ -122,8 +183,14 @@ const props = defineProps<{
|
||||
summary: { total: number; green: number; yellow: number; red: number; health_score?: number } | null
|
||||
switchInfo?: { has_switch?: boolean; label?: string; effective_date?: string | null; comparability?: string; note?: string } | null
|
||||
loading?: boolean
|
||||
/** ⑤ 目标·连线:当前地图目标数(null ⇒ 显式「—」) */
|
||||
objectivesTotal?: number | null
|
||||
/** ⑤ 目标·连线:当前地图连线数(null ⇒ 显式「—」) */
|
||||
connectionsTotal?: number | null
|
||||
}>()
|
||||
|
||||
const detailOpen = ref(false)
|
||||
|
||||
const revenueLines = computed<RevLine[]>(() => (props.revenue?.lines || []).filter(l => !!l && !!l.key))
|
||||
/** 合计 = 各线相加的复算校验位,不与分线并列成第二套口径 */
|
||||
const revenueTotal = computed<number | null>(() => {
|
||||
@@ -163,92 +230,125 @@ function fmtNum(v: number | null | undefined): string {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* C 方案:单行汇总条(行高 40px + 上下 1px 边框 ≤44px) */
|
||||
.map-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sum-card {
|
||||
margin-bottom: 8px;
|
||||
background: var(--bh-bg-card, #fff);
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.ms-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sum-card--wide { grid-column: span 2; }
|
||||
.sum-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.sum-label {
|
||||
.ms-metric {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ms-metric--rev { flex: 0 1 auto; overflow: hidden; }
|
||||
.ms-label {
|
||||
font-size: 12px;
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
letter-spacing: .02em;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sum-badge {
|
||||
.ms-value {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--bh-text-primary, #0F172A);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ms-value.muted { font-size: 13px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
|
||||
.ms-unit {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.ms-div { color: var(--bh-text-muted, #94A3B8); margin: 0 2px; }
|
||||
.ms-sep { width: 1px; height: 18px; background: var(--bh-border, #E5E7EB); flex-shrink: 0; }
|
||||
.ms-badge {
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
padding: 1px 6px;
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sum-badge.calm { color: #635BFF; background: rgba(99, 91, 255, .08); }
|
||||
.sum-badge.warn { color: #B45309; background: #FEF3C7; }
|
||||
.sum-badge.warn em { font-style: normal; }
|
||||
.sum-value {
|
||||
margin-top: 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--bh-text-primary, #0F172A);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
}
|
||||
.sum-value.muted { font-size: 15px; color: var(--bh-text-muted, #94A3B8); font-weight: 500; }
|
||||
.sum-unit { font-size: 11px; font-weight: 500; font-style: normal; color: var(--bh-text-secondary, #64748B); margin-left: 2px; }
|
||||
.sum-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--bh-text-muted, #94A3B8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rev-lines { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.rev-line-label { font-size: 12px; color: var(--bh-text-secondary, #64748B); }
|
||||
.rev-line-value { font-size: 18px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
|
||||
.rev-line-value.muted { font-size: 14px; font-weight: 500; color: var(--bh-text-muted, #94A3B8); }
|
||||
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); padding-top: 3px; margin-top: 2px; }
|
||||
.rev-line--total .rev-line-label,
|
||||
.rev-line--total .rev-line-value { font-size: 12px; font-weight: 500; color: var(--bh-text-secondary, #64748B); }
|
||||
.sum-foot {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.ms-badge.calm { color: #0369A1; background: #F0F9FF; border-color: #BAE6FD; }
|
||||
.ms-badge.warn { color: #B45309; background: #FFFBEB; border-color: #FDE68A; }
|
||||
.ms-badge.warn em { font-style: normal; }
|
||||
.ms-detail-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px 16px;
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--bh-border, #E5E7EB);
|
||||
border-radius: 6px;
|
||||
gap: 3px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
color: var(--bh-text-muted, #94A3B8);
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
background: transparent;
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mgmt { display: inline-flex; align-items: baseline; gap: 6px; }
|
||||
.ms-detail-btn:hover { color: #0F172A; border-color: #CBD5E1; }
|
||||
.ms-detail-btn svg { width: 12px; height: 12px; }
|
||||
|
||||
/* 明细展开区 */
|
||||
.ms-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
.ms-card {
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
min-width: 0;
|
||||
background: var(--bh-bg-subtle, #FCFCFD);
|
||||
}
|
||||
.ms-detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rev-lines { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rev-line { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; min-width: 0; }
|
||||
.rev-line-label { font-size: 11px; color: var(--bh-text-secondary, #64748B); }
|
||||
.rev-line-value { font-size: 12px; color: var(--bh-text-primary, #0F172A); }
|
||||
.rev-line-value.muted { color: var(--bh-text-muted, #94A3B8); }
|
||||
.rev-line--total { border-top: 1px dashed var(--bh-border, #E5E7EB); margin-top: 2px; padding-top: 2px; }
|
||||
.ms-sub { margin-top: 4px; font-size: 11px; color: var(--bh-text-muted, #94A3B8); line-height: 1.5; }
|
||||
.ms-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
|
||||
.mgmt { display: inline-flex; align-items: baseline; gap: 4px; font-size: 11px; }
|
||||
.mgmt-label { color: var(--bh-text-secondary, #64748B); }
|
||||
.mgmt-value { font-size: 13px; font-weight: 700; color: var(--bh-text-primary, #0F172A); }
|
||||
.mgmt-tag { font-size: 10px; padding: 1px 5px; border-radius: 4px; background: #F1F5F9; color: #475569; }
|
||||
.mgmt-abnormal { color: #B91C1C; }
|
||||
.mgmt-caliber { color: var(--bh-text-muted, #94A3B8); }
|
||||
.mgmt-value { color: var(--bh-text-primary, #0F172A); font-weight: 600; }
|
||||
.mgmt-tag { font-size: 10px; padding: 0 4px; border-radius: 4px; background: #F1F5F9; color: #475569; }
|
||||
.mgmt-abnormal { font-size: 10px; color: #B91C1C; }
|
||||
.mono { font-family: 'IBM Plex Mono', Consolas, 'SFMono-Regular', monospace; font-variant-numeric: tabular-nums; }
|
||||
.lv-green { color: #00C896; }
|
||||
.lv-yellow { color: #F59E0B; }
|
||||
.lv-red { color: #EF4444; }
|
||||
@media (max-width: 1200px) {
|
||||
.map-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sum-card--wide { grid-column: span 2; }
|
||||
|
||||
/* 窄屏:单行改为可横向滚动,保留全部 5 项、不换行不截断 */
|
||||
@media (max-width: 1280px) {
|
||||
.ms-row { overflow-x: auto; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<template>
|
||||
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
|
||||
<div class="layer-header" :style="{ background: color }">
|
||||
<div
|
||||
class="layer-header"
|
||||
:class="{ 'is-focusable': true, 'is-focused': focused }"
|
||||
:style="{ background: color }"
|
||||
:title="focused ? '再次点击退出聚焦,恢复四层等宽' : '点击聚焦该层(其余三层压成单行摘要)'"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="focused ? 'true' : 'false'"
|
||||
@click="$emit('focus')"
|
||||
@keydown.enter.prevent="$emit('focus')"
|
||||
@keydown.space.prevent="$emit('focus')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="layer-collapse-btn"
|
||||
@@ -14,6 +25,12 @@
|
||||
</button>
|
||||
<span class="layer-icon">{{ icon }}</span>
|
||||
<span class="layer-name">{{ label }}</span>
|
||||
<!-- 压缩态(C 方案聚焦模式):保「目标数 + 达成率色条」,整层信息不全隐 -->
|
||||
<span v-if="compact" class="layer-mini" :aria-label="'该层达成率 ' + ratioText">
|
||||
<span class="mini-track"><i class="mini-fill" :style="{ width: ratioBarWidth }"></i></span>
|
||||
<span class="mini-ratio mono">{{ ratioText }}</span>
|
||||
</span>
|
||||
<span v-if="focused" class="layer-focus-flag">聚焦中</span>
|
||||
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
|
||||
<el-dropdown trigger="click" @command="onLayerAction">
|
||||
<el-button class="layer-more-btn" size="small" text>
|
||||
@@ -90,7 +107,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { MoreFilled } from '@element-plus/icons-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import MapNode from './MapNode.vue'
|
||||
@@ -115,6 +132,12 @@ const props = defineProps<{
|
||||
dimmedKeys?: string[]
|
||||
/** 层折叠(仅隐藏层体,保留层头) */
|
||||
collapsed?: boolean
|
||||
/** C 方案聚焦模式:其它层压成单行(层头 + 目标数 + 达成率色条) */
|
||||
compact?: boolean
|
||||
/** 该层达成率均值(无实测值传 null,界面显式「—」,不用示例数兜底) */
|
||||
layerRatio?: number | null
|
||||
/** 本层是否处于聚焦展开态 */
|
||||
focused?: boolean
|
||||
getNodeLevel: (key: string) => string
|
||||
getNodeProgress: (key: string) => any
|
||||
}>()
|
||||
@@ -138,8 +161,21 @@ const emit = defineEmits<{
|
||||
(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
|
||||
(e: 'focus'): void
|
||||
}>()
|
||||
|
||||
/** 压缩态达成率文案:无实测值显式「—」,禁止示例数字兜底 */
|
||||
const ratioText = computed(() => {
|
||||
const r = props.layerRatio
|
||||
return r == null || Number.isNaN(Number(r)) ? '—' : `${Math.round(Number(r))}%`
|
||||
})
|
||||
/** 压缩态色条宽度(0–100%,无数据时 0 宽 + 灰色轨道) */
|
||||
const ratioBarWidth = computed(() => {
|
||||
const r = Number(props.layerRatio)
|
||||
if (props.layerRatio == null || Number.isNaN(r)) return '0%'
|
||||
return `${Math.max(0, Math.min(100, Math.round(r)))}%`
|
||||
})
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Node DOM refs for line calculation (reactive so parent can aggregate)
|
||||
@@ -251,6 +287,54 @@ export default {
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* C 方案:层头可点击聚焦 */
|
||||
.layer-header.is-focusable { cursor: pointer; }
|
||||
.layer-header.is-focusable:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.layer-header.is-focused { box-shadow: inset 0 -2px 0 rgba(255, 255, 255, 0.55); }
|
||||
|
||||
.layer-focus-flag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .04em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 压缩单行态:达成率色条 + 百分比(保信息不全隐) */
|
||||
.layer-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
min-width: 96px;
|
||||
}
|
||||
.mini-track {
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
.mini-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
transition: width .2s ease;
|
||||
}
|
||||
.mini-ratio {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: .95;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
:achievement-count="achievementCount"
|
||||
:summary="reviewSummary"
|
||||
:loading="loading"
|
||||
:objectives-total="totalObjectiveCount"
|
||||
:connections-total="connections.length"
|
||||
/>
|
||||
|
||||
<MapEditBar
|
||||
@@ -43,6 +45,7 @@
|
||||
:layer-configs="layerUiConfigs"
|
||||
:can-undo="undoStack.length > 0"
|
||||
:tree-open="treeOpen"
|
||||
:focus-layer="focusLayer"
|
||||
@update:search="v => searchText = v"
|
||||
@update:filter="v => filterMode = v"
|
||||
@toggle-layer="toggleLayer"
|
||||
@@ -52,6 +55,7 @@
|
||||
@fit="zoomFit"
|
||||
@toggle-tree="treeOpen = !treeOpen"
|
||||
@completeness="showCompleteness = true"
|
||||
@focus-layer="toggleFocusLayer"
|
||||
/>
|
||||
|
||||
<!-- 口径标识(常驻,非 hover 临时提示):A 口径 v1.1 来自会计科目、非发票同票列明 -->
|
||||
@@ -80,7 +84,7 @@
|
||||
<el-button size="small" @click="cancelLink">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 仪表盘主视图:左目标树 / 中画布 / 右详情 -->
|
||||
<!-- 仪表盘主视图:左目标树(默认收起,留 46px 图标条)/ 中画布 / 详情浮层 -->
|
||||
<div class="map-body-row">
|
||||
<MapTargetTree
|
||||
v-show="treeOpen"
|
||||
@@ -98,6 +102,22 @@
|
||||
@drag-end="onTreeDragEnd"
|
||||
/>
|
||||
|
||||
<!-- 目标树收起后的 46px 图标条(C 方案:默认收起,一键展开) -->
|
||||
<div v-show="!treeOpen" class="tree-rail" aria-label="目标树已收起">
|
||||
<button
|
||||
type="button"
|
||||
class="rail-btn"
|
||||
title="展开目标树"
|
||||
aria-label="展开目标树"
|
||||
@click="treeOpen = true"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path d="M4 6h6M4 12h6M4 18h6" /><path d="M14 6h6M14 12h6M14 18h6" opacity=".45" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="rail-count mono" :title="'当前可见目标 / 总目标'">{{ visibleObjectiveCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="map-canvas-col">
|
||||
<!-- 画布主体(田字格2×2布局,transform: scale 缩放) -->
|
||||
<div
|
||||
@@ -108,7 +128,7 @@
|
||||
@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 class="canvas-body" :class="{ 'grid-mode': gridMode, 'focus-active': !!focusLayer }" ref="bodyRef" :style="bodyZoomStyle">
|
||||
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
|
||||
<div
|
||||
v-for="(cfg, idx) in orderedLayerConfigs"
|
||||
@@ -116,6 +136,10 @@
|
||||
v-show="visibleLayers[cfg.key]"
|
||||
:ref="el => setLayerRef(cfg.key, el)"
|
||||
class="layer-swimlane-wrapper"
|
||||
:class="{
|
||||
'is-focused': focusLayer === cfg.key,
|
||||
'is-compact': !!focusLayer && focusLayer !== cfg.key,
|
||||
}"
|
||||
:style="{ borderLeft: '4px solid ' + layerColor(cfg.key) }"
|
||||
>
|
||||
<StrategyLayer
|
||||
@@ -126,6 +150,10 @@
|
||||
:color="layerColor(cfg.key)"
|
||||
:objectives="getLayerNodes(cfg.key)"
|
||||
:collapsed="!!collapsedLayers[cfg.key]"
|
||||
:compact="!!focusLayer && focusLayer !== cfg.key"
|
||||
:focused="focusLayer === cfg.key"
|
||||
:layer-ratio="layerStats[cfg.key] ? layerStats[cfg.key].ratio : null"
|
||||
@focus="toggleFocusLayer(cfg.key)"
|
||||
:dim-active="filterActive"
|
||||
:dimmed-keys="matchedKeyList"
|
||||
@toggle-collapse="toggleLayerCollapse(cfg.key)"
|
||||
@@ -174,10 +202,40 @@
|
||||
<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
|
||||
<!-- 跨层关系摘要条(C 方案):数字由 connections 的 from/to 现算,不写死 -->
|
||||
<div class="cross-summary" aria-label="跨层关系摘要">
|
||||
<span class="cs-title">跨层关系摘要</span>
|
||||
<span v-for="item in crossSummary" :key="item.key" class="cs-item">
|
||||
<i class="cs-dot" :style="{ background: item.color }"></i>
|
||||
<span class="cs-label">{{ item.label }}</span>
|
||||
<b class="cs-num mono">{{ item.count }}</b>
|
||||
</span>
|
||||
<span class="cs-total">
|
||||
合计 <b class="mono">{{ connections.length }}</b> 条 · 跨层 <b class="mono">{{ crossLayerCount }}</b> 条 · 同层 <b class="mono">{{ sameLayerCount }}</b> 条
|
||||
</span>
|
||||
</div>
|
||||
</div><!-- end map-canvas-col -->
|
||||
</div><!-- end map-body-row -->
|
||||
|
||||
<!-- 详情浮层(C 方案):右栏不再常驻占位,画布按视口 100% 宽;浮层可拖动、可关闭 -->
|
||||
<div
|
||||
v-if="selectedNode"
|
||||
class="detail-float"
|
||||
:style="{ left: floatPos.x + 'px', top: floatPos.y + 'px' }"
|
||||
role="dialog"
|
||||
aria-label="目标详情浮层"
|
||||
>
|
||||
<div class="float-bar" @mousedown="startFloatDrag">
|
||||
<span class="float-bar-grip" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 8h.01M8 12h.01M8 16h.01M16 8h.01M16 12h.01M16 16h.01" /></svg>
|
||||
</span>
|
||||
<span class="float-bar-title">拖动可移动</span>
|
||||
<button type="button" class="float-bar-close" aria-label="关闭详情浮层" @click="closeDetail">
|
||||
<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>
|
||||
</div>
|
||||
<MapNodeDetail
|
||||
:obj="selectedNode.obj"
|
||||
:dim-key="selectedNode.dimKey"
|
||||
:index="selectedNode.index"
|
||||
@@ -196,13 +254,7 @@
|
||||
@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 -->
|
||||
</div>
|
||||
|
||||
<!-- SVG 连线层(覆盖整个画布,绘制跨层+同层箭头) -->
|
||||
<ConnectionLines
|
||||
@@ -211,7 +263,7 @@
|
||||
:node-refs="compiledNodeRefs"
|
||||
:layer-keys="layerKeysOrdered"
|
||||
:conn-keys="connKeys"
|
||||
:connections="connections"
|
||||
:connections="renderedConnections"
|
||||
:temp-line="tempLine"
|
||||
:container-key="recalcTrigger"
|
||||
:view-direction="viewDirection"
|
||||
@@ -390,7 +442,35 @@ 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)
|
||||
|
||||
/* ── C 方案:目标树默认收起 + 记住上次选择(localStorage 键名 cma.map.canvas.treeOpen)── */
|
||||
const TREE_OPEN_STORAGE_KEY = 'cma.map.canvas.treeOpen'
|
||||
function readTreeOpenPref(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(TREE_OPEN_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const treeOpen = ref<boolean>(readTreeOpenPref())
|
||||
watch(treeOpen, (v) => {
|
||||
try {
|
||||
localStorage.setItem(TREE_OPEN_STORAGE_KEY, v ? '1' : '0')
|
||||
} catch {
|
||||
/* 隐私模式/配额满:静默降级为不记忆,不影响画布功能 */
|
||||
}
|
||||
})
|
||||
|
||||
/* ── C 方案:聚焦模式(null = 四层全景;非 null = 该层展开、其余三层压成单行)── */
|
||||
const focusLayer = ref<string | null>(null)
|
||||
function toggleFocusLayer(key: string | null) {
|
||||
focusLayer.value = !key || focusLayer.value === key ? null : key
|
||||
nextTick(() => {
|
||||
updateGridMode()
|
||||
triggerRecalc()
|
||||
updateZoomWrapHeight()
|
||||
})
|
||||
}
|
||||
|
||||
const selectedNode = ref<{ dimKey: string; index: number; obj: any } | null>(null)
|
||||
const selectedKey = computed(() => (selectedNode.value ? `${selectedNode.value.dimKey}-${selectedNode.value.index}` : null))
|
||||
@@ -525,6 +605,112 @@ const achievementCount = computed(() =>
|
||||
Object.values(kpiProgressMap).filter((p: any) => p.hasData && p.ratio != null).length
|
||||
)
|
||||
|
||||
/** 各层达成率均值(聚焦压缩态色条用):仅统计有实测值的目标,无数据 ⇒ null(界面显式「—」) */
|
||||
const layerStats = computed<Record<string, { ratio: number | null; count: number }>>(() => {
|
||||
const out: Record<string, { ratio: number | null; count: number }> = {}
|
||||
for (const cfg of orderedLayerConfigs) {
|
||||
const objs = getLayerNodes(cfg.key)
|
||||
const vals: number[] = []
|
||||
objs.forEach((_: any, i: number) => {
|
||||
const p: any = kpiProgressMap[`${cfg.key}-${i}`]
|
||||
if (p && p.hasData && p.ratio != null && !Number.isNaN(Number(p.ratio))) vals.push(Number(p.ratio))
|
||||
})
|
||||
out[cfg.key] = { ratio: vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null, count: objs.length }
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/* ── C 方案:跨层关系摘要(全部由 connections 的 from/to 现算,禁写死)──
|
||||
* 连线端点兼容两种键:渲染键 `finance-0` 与数据键 `learning:目标名`(StrategyLayer 双键注册)。
|
||||
*/
|
||||
function layerOfNodeKey(key: string | null | undefined): string | null {
|
||||
if (!key || typeof key !== 'string') return null
|
||||
const sep = key.includes(':') ? ':' : '-'
|
||||
const layer = key.split(sep)[0]
|
||||
return LAYER_KEYS.includes(layer as any) ? layer : null
|
||||
}
|
||||
const layerHue = (key: string) => LAYER_VISUAL[key]?.color || LAYER_CONFIG[key]?.color || '#635BFF'
|
||||
/** 聚焦态只绘制聚焦层内部的同层连线(跨层线跨向被压缩层时无锚点);全景态全量绘制 */
|
||||
const renderedConnections = computed<any[]>(() => {
|
||||
const f = focusLayer.value
|
||||
if (!f) return connections.value
|
||||
return connections.value.filter(
|
||||
(c: any) => layerOfNodeKey(c?.from) === f && layerOfNodeKey(c?.to) === f
|
||||
)
|
||||
})
|
||||
const crossLayerCount = computed(() =>
|
||||
connections.value.filter((c: any) => {
|
||||
const f = layerOfNodeKey(c?.from)
|
||||
const t = layerOfNodeKey(c?.to)
|
||||
return !!f && !!t && f !== t
|
||||
}).length
|
||||
)
|
||||
const sameLayerCount = computed(() => connections.value.length - crossLayerCount.value)
|
||||
/** BSC 三段因果 + 「其他跨层」兜底桶 + 同层;合计必须等于 connections.length */
|
||||
const crossSummary = computed(() => {
|
||||
const dirs: { key: string; label: string; from: string; to: string }[] = [
|
||||
{ key: 'learn2proc', label: '学习→流程', from: 'learning', to: 'process' },
|
||||
{ key: 'proc2cust', label: '流程→客户', from: 'process', to: 'customer' },
|
||||
{ key: 'cust2fin', label: '客户→财务', from: 'customer', to: 'finance' },
|
||||
]
|
||||
const counts: Record<string, number> = {}
|
||||
let other = 0
|
||||
for (const c of connections.value as any[]) {
|
||||
const f = layerOfNodeKey(c?.from)
|
||||
const t = layerOfNodeKey(c?.to)
|
||||
if (!f || !t || f === t) continue
|
||||
const hit = dirs.find(d => d.from === f && d.to === t)
|
||||
if (hit) {
|
||||
counts[hit.key] = (counts[hit.key] || 0) + 1
|
||||
} else {
|
||||
other++
|
||||
}
|
||||
}
|
||||
const items = dirs.map(d => ({
|
||||
key: d.key,
|
||||
label: d.label,
|
||||
count: counts[d.key] || 0,
|
||||
color: layerHue(d.to),
|
||||
}))
|
||||
if (other > 0) items.push({ key: 'other', label: '其他跨层', count: other, color: '#94A3B8' })
|
||||
return items
|
||||
})
|
||||
|
||||
/* ── C 方案:详情浮层位置与拖动(自绘 div + transform,非 el-dialog)── */
|
||||
const floatPos = ref<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const floatDragging = ref(false)
|
||||
let floatDragOffset = { x: 0, y: 0 }
|
||||
const FLOAT_W = 360
|
||||
function clampFloat(x: number, y: number) {
|
||||
const maxX = Math.max(8, window.innerWidth - FLOAT_W - 12)
|
||||
const maxY = Math.max(8, window.innerHeight - 120)
|
||||
return { x: Math.min(Math.max(8, x), maxX), y: Math.min(Math.max(8, y), maxY) }
|
||||
}
|
||||
/** 默认落位:右侧靠上(不遮挡左侧层名与中部卡片) */
|
||||
function placeFloatDefault() {
|
||||
floatPos.value = clampFloat(window.innerWidth - FLOAT_W - 24, 132)
|
||||
}
|
||||
function startFloatDrag(e: MouseEvent) {
|
||||
floatDragging.value = true
|
||||
floatDragOffset = { x: e.clientX - floatPos.value.x, y: e.clientY - floatPos.value.y }
|
||||
window.addEventListener('mousemove', onFloatDragMove)
|
||||
window.addEventListener('mouseup', stopFloatDrag)
|
||||
e.preventDefault()
|
||||
}
|
||||
function onFloatDragMove(e: MouseEvent) {
|
||||
if (!floatDragging.value) return
|
||||
floatPos.value = clampFloat(e.clientX - floatDragOffset.x, e.clientY - floatDragOffset.y)
|
||||
}
|
||||
function stopFloatDrag() {
|
||||
floatDragging.value = false
|
||||
window.removeEventListener('mousemove', onFloatDragMove)
|
||||
window.removeEventListener('mouseup', stopFloatDrag)
|
||||
}
|
||||
watch(selectedNode, (v) => {
|
||||
if (v && !floatPos.value.x && !floatPos.value.y) placeFloatDefault()
|
||||
})
|
||||
onUnmounted(() => stopFloatDrag())
|
||||
|
||||
/** 搜索/筛选是否激活(激活时画布非匹配节点变淡) */
|
||||
const filterActive = computed(() => searchText.value.trim() !== '' || filterMode.value !== 'all')
|
||||
|
||||
@@ -2176,6 +2362,140 @@ onUnmounted(() => {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── C 方案:聚焦模式 ── */
|
||||
/* 聚焦层:田字格下占满整行,卡片网格展开 */
|
||||
.canvas-body.grid-mode .layer-swimlane-wrapper.is-focused {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
/* 其余三层:压成单行(≤48px,仅层头 + 目标数 + 达成率色条) */
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact {
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
max-height: 48px;
|
||||
overflow: hidden;
|
||||
align-self: start;
|
||||
}
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.strategy-layer) {
|
||||
min-height: 0;
|
||||
height: 48px;
|
||||
}
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.layer-body) {
|
||||
display: none;
|
||||
}
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-compact :deep(.layer-header) {
|
||||
padding: 6px 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
.canvas-body.focus-active .layer-swimlane-wrapper.is-focused :deep(.strategy-layer) {
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
/* ── 目标树收起后的 46px 图标条 ── */
|
||||
.tree-rail {
|
||||
width: 46px;
|
||||
flex: 0 0 46px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 8px;
|
||||
background: var(--bh-bg-card, #fff);
|
||||
}
|
||||
.rail-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--bh-border, #E5E7EB);
|
||||
border-radius: 6px;
|
||||
background: #F8FAFC;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-btn:hover { background: #EEF2F7; color: #0F172A; }
|
||||
.rail-btn svg { width: 16px; height: 16px; }
|
||||
.rail-count {
|
||||
font-size: 11px;
|
||||
color: var(--bh-text-secondary, #64748B);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── 详情浮层(自绘 div,可拖动,非 el-dialog) ── */
|
||||
.detail-float {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
width: 360px;
|
||||
max-height: calc(100vh - 180px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bh-bg-card, #fff);
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16), 0 2px 6px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-float :deep(.node-detail) {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.float-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
background: #F8FAFC;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.float-bar-grip { display: inline-flex; color: #94A3B8; }
|
||||
.float-bar-grip svg { width: 14px; height: 14px; }
|
||||
.float-bar-title { font-size: 11px; color: #94A3B8; flex: 1; }
|
||||
.float-bar-close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748B;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.float-bar-close:hover { background: #E2E8F0; color: #0F172A; }
|
||||
.float-bar-close svg { width: 12px; height: 12px; }
|
||||
|
||||
/* ── 跨层关系摘要条 ── */
|
||||
.cross-summary {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding: 6px 2px 0;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
border-top: 1px dashed var(--bh-border, #E5E7EB);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cs-title { color: #94A3B8; flex-shrink: 0; }
|
||||
.cs-item { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
.cs-dot { width: 7px; height: 7px; border-radius: 2px; display: inline-block; }
|
||||
.cs-label { color: #64748B; }
|
||||
.cs-num { color: #0F172A; font-variant-numeric: tabular-nums; }
|
||||
.cs-total { margin-left: auto; color: #64748B; flex-shrink: 0; }
|
||||
.cs-total b { color: #0F172A; }
|
||||
|
||||
/* ── 跨层箭头指示器 ── */
|
||||
.cross-layer-arrow {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user