| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- <!--
- 二级面板外框:切角科技面板 + 可点击收放的标题栏。
- 视觉参考《驾驶舱原型图-美化版》的 .panel / .panel-head;
- 尺寸随外壳的 --u 设计像素缩放(大屏不留空,小屏不缩小)。
- 标题栏是 button,其文本即按钮的可访问名(标记符号 aria-hidden,不参与命名)。
- -->
- <template>
- <section
- class="cockpit-panel"
- :class="{ 'is-collapsed': !expanded }"
- :style="{ '--panel-grow': effectiveGrow, '--panel-basis': basis || '0%' }"
- :data-panel-id="panelId"
- :data-panel-grow="Math.round(effectiveGrow * 10) / 10"
- :data-panel-measured="measuredGrow ? 'yes' : 'no'"
- >
- <div class="cockpit-panel__heading">
- <button
- type="button"
- class="cockpit-panel__head"
- :aria-expanded="expanded"
- @click="emit('toggle')"
- >
- <span class="cockpit-panel__title">
- <!-- 批次 1:面板图标按 panelId 取自蓝湖正式切图;纯装饰,aria-hidden 不参与命名。 -->
- <CockpitIcon v-if="iconName" :name="iconName" :size="panelIconSize" class="cockpit-panel__icon" />
- {{ title }}
- </span>
- <small class="cockpit-panel__meta" :class="{ 'is-end': metaAlign === 'end' }">{{ meta }}</small>
- <span class="cockpit-panel__mark" aria-hidden="true">{{ expanded ? '−' : '+' }}</span>
- </button>
- <SectionStateTag v-if="section" class="cockpit-panel__state" :name="panelId" :label="title" :section="section" />
- </div>
- <!-- v-show 而非 v-if:收起再展开不应重建面板内容或触发重新取数。 -->
- <div v-show="expanded" class="cockpit-panel__body">
- <!--
- 内容包裹层(2026-10-23 维护者第 1 条:面板高度自适应作为**基本规则**):
- 这一层的高度=内容自然高度(不被拉伸),量它就知道"这块面板要多少空间",
- 再按它给 flex 权重。量 body 本身不行 —— body 会被 flex 拉伸,
- scrollHeight 会跟着分配高度变,权重会自我强化。
- -->
- <div ref="contentRef" class="cockpit-panel__content">
- <slot />
- </div>
- </div>
- </section>
- </template>
- <script setup>
- import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
- import CockpitIcon from './CockpitIcon.vue'
- import SectionStateTag from './SectionStateTag.vue'
- import { panelIcon } from '../domain/panelCatalog.js'
- const props = defineProps({
- title: { type: String, required: true },
- meta: { type: String, default: '' },
- /**
- * 标题右侧小字的对齐:`''`(缺省,左对齐,跟随标题)/ `'end'`(贴右)。
- *
- * 为什么是**局部 prop** 而不是改全局:overview 四块的右上角小字是规格 §6.1 的
- * 「右上角」要求(设备规模·类型·片区分布 等),而其它模式的面板 meta 一直是
- * 「标题右侧的数据提要」,两条语义不同 —— 全局改会把 12 块面板一起挪位。
- */
- metaAlign: { type: String, default: '' },
- section: { type: Object, default: null },
- expanded: { type: Boolean, default: true },
- /**
- * 该面板在所在栏里的**高度权重**(2026-10-23 维护者:面板高度按内容自适应)。
- *
- * 取值由外壳按「这块面板大概有多少内容」给:默认 1(两块等分),内容密的给 >1、
- * 内容简的给 <1。它是相对权重不是一个尺寸 —— flex-grow 本来就是「剩下多少按权重分」,
- * 所以换屏幕高度不用重算。缺省 1 表示"不关心,按等分"。
- */
- grow: { type: Number, default: 0 },
- /**
- * 这块面板是否**以表格/清单为主体**(外壳按「有没有 list 块」算好传进来)。
- *
- * 为什么要传:自适应权重按内容自然高度量,表格行数上百时高度虚高,
- * 会把同列另一块挤成一条缝。渲染层仍不认识面板身份 —— 它只认这个布尔标志。
- */
- tableHeavy: { type: Boolean, default: false },
- /**
- * 这块面板在**所在栏**里的高度份额(如 `"38.50%"`),由外壳按同栏各块权重归一算出。
- *
- * 为什么由外壳给:份额是同栏几块之间的相对分配,单块看不到兄弟、纯 CSS 也算不出权重和。
- * 它落到 `flex-basis` 上 —— 下限用 basis 表达而不是 `min-height`,
- * 因为百分比 `min-height` 会把整栏先顶满、让权重分配几乎失效(见样式里的说明)。
- */
- basis: { type: String, default: '' },
- /**
- * 面板的稳定英文键(`domain/panelCatalog.js` 的 panelId)。
- * 走查与断言读它来认面板 —— 不能从面板体内的文案反推:
- * 面板体可能是占位、也可能是真实内容,**断言不该依赖某一种**。
- */
- panelId: { type: String, default: '' },
- })
- const emit = defineEmits(['toggle'])
- /**
- * 面板高度自适应(维护者 2026-10-23 第 1 条:**基本规则**,按内容设权重)。
- *
- * 做法:量内容包裹层的自然高度,把它作为 flex-grow 的权重 —— flex 分配剩余空间时
- * 本来就是「按权重分」,于是"内容多的面板拿到更多高度"是**测量结果**而不是手填常量。
- *
- * 为什么不量 body:body 被 flex 拉伸,scrollHeight 会跟着分配高度走,
- * 权重自我强化(分得多 → 量得大 → 分得更多)。内容层是 auto 高度,量它才稳。
- *
- * 兜底:还没量到(首帧/SSR)时用外壳给的 grow;两者都没有就回 1(等分)。
- * 量到 0(空面板)时也用 1,避免除零式塌陷。
- */
- const contentRef = ref(null)
- const measuredGrow = ref(0)
- let resizeObserver = null
- /**
- * 表格类面板的权重打折(维护者 2026-10-23 反馈第 3 条)。
- *
- * 为什么需要:自适应权重按内容自然高度量,而**表格/清单**(预警处置的「当前预警」「历史分析」)
- * 每行高度固定、行数却可能上百 —— 量出来的高度能是旁边面板的十几倍,于是同列的另一块
- * 被挤到只剩标题栏。维护者原话:「带有表格的面板权重会变得非常高…需要保留一个下限比例,
- * 至少能够显示面板的大部分区域」。
- *
- * 两件事同时做:
- * 1. **打折**:表格面板的权重乘 `TABLE_GROW_DISCOUNT`(内容多不等于该占那么多版面);
- * 2. **下限**:同一栏里,权重不得低于该栏最大权重的 `MIN_GROW_RATIO` ——
- * 保证被挤压的那块至少还剩这一比例的高度,看得见大部分内容。
- *
- * 判据是**有没有表格块**(`block.kind === 'list'`),不是面板 id —
- * 渲染层不认识面板身份这条纪律不变;面板身份由外壳算好传进来(`tableHeavy`)。
- */
- const TABLE_GROW_DISCOUNT = 0.35
- /** 同栏内任一面板的权重不得低于最大权重的这个比例(即至少分到约 1/3 的高度)。 */
- const MIN_GROW_RATIO = 0.34
- function measureContent() {
- const element = contentRef.value
- if (!element) return
- // scrollHeight 取内容自然高度(本层不设 overflow,等价于 offsetHeight 的稳定版)。
- const height = element.scrollHeight || element.offsetHeight || 0
- if (height <= 0) {
- measuredGrow.value = 0
- return
- }
- // 表格面板打折后再交给外壳做同栏归一(下限在那里统一施加,见 `normalizeGrow`)。
- measuredGrow.value = props.tableHeavy ? height * TABLE_GROW_DISCOUNT : height
- }
- onMounted(() => {
- measureContent()
- // 内容变化(取数到达、筛选变化、宽窄重排、展开/收起)都要重新量 ——
- // 监听**内容层自己**的尺寸:收起时 v-show 置 display:none,量到 0 就回落
- // `props.grow`(此时权重本就不参与分配,因为 is-collapsed 已 flex:0 0 auto);
- // 再展开时 display 变化会触发 ResizeObserver,重新拿到真实高度。
- if (typeof ResizeObserver === 'function' && contentRef.value) {
- resizeObserver = new ResizeObserver(measureContent)
- resizeObserver.observe(contentRef.value)
- }
- })
- onBeforeUnmount(() => {
- if (resizeObserver) resizeObserver.disconnect()
- resizeObserver = null
- })
- /** 实际生效的权重:测量值优先,其次外壳声明,最后 1。 */
- const effectiveGrow = computed(() => measuredGrow.value || props.grow || 1)
- /** 面板图标语义名;无对应图标时为空串 → 不渲染图标,不抛错。 */
- const iconName = computed(() => panelIcon(props.panelId))
- /** 与 15.5u 的标题字号配套的图标边长(走查反馈偏小,由 14 上调)。 */
- const panelIconSize = 'calc(17 * var(--u, 1px))'
- </script>
- <style scoped>
- /* 组件内自带的盒模型重置(不依赖全局 `*` 规则)。 */
- .cockpit-panel,
- .cockpit-panel *,
- .cockpit-panel *::before,
- .cockpit-panel *::after {
- box-sizing: border-box;
- }
- /* 切角描边:四角各一段青色 L 形,与原型图同一套 SVG(内联为自定义属性)。
- 批次 5(票 18)收口:母版只承载几何(stroke 白色,mask 只取 alpha),
- 颜色统一经 mask + background-color 取 --panel-line-hi —— 此前四个 data-URI
- 各自内联主蓝色值属主题色散落(规格 03 判据:几何可代码化,色值必须走 token)。 */
- .cockpit-panel {
- --corner-tl: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M0.8 16 V3.3 A2.5 2.5 0 0 1 3.3 0.8 H16' fill='none' stroke='%23fff' stroke-width='1.7'/%3E%3C/svg%3E");
- --corner-tr: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M0 0.8 H12.7 A2.5 2.5 0 0 1 15.2 3.3 V16' fill='none' stroke='%23fff' stroke-width='1.7'/%3E%3C/svg%3E");
- --corner-bl: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M0.8 0 V12.7 A2.5 2.5 0 0 0 3.3 15.2 H16' fill='none' stroke='%23fff' stroke-width='1.7'/%3E%3C/svg%3E");
- --corner-br: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath d='M0 15.2 H12.7 A2.5 2.5 0 0 0 15.2 12.7 V0' fill='none' stroke='%23fff' stroke-width='1.7'/%3E%3C/svg%3E");
- position: relative;
- display: flex;
- /* 展开时参与分配所在栏的高度。
- 2026-10-23(维护者):**不再硬等分**,改为按内容高度加权 —— `--panel-grow` 由外壳
- 按该面板内容的估算高度给出默认比例(内容多的拿更多),没给时退回 1:1。
- 至少保留 `min-height`,否则内容少的那块会被压到只剩标题栏。 */
- /* 高度分配(维护者 2026-10-23 反馈第 3 条:表格面板不能把同栏挤没)。
- **下限必须用 flex-basis 表达,不能用 min-height**:
- 百分比 `min-height` 在 flex 列里相对父容器高度解析,两块各占 34%/42% 的 min
- 会先把整栏顶满(合计 76%),剩下 24% 才按 grow 分 —— 于是"打折"几乎失效,
- 而内容少的那块被顶到 34% 后又被撑出大片空白。
- 改成 basis = 权重份额:`calc(权重 ÷ 权重和 × 100%)` 在 flex 里就是"先按份额预分",
- 剩余空间再按 grow 微调,打折与下限同时成立。
- 纯 CSS 算不出同栏权重和,所以外壳把份额算好写成 `--panel-basis`(见 columnBasis)。 */
- flex: var(--panel-grow, 1) 1 var(--panel-basis, 0%);
- /* 硬护栏:任何情况下至少露出标题栏 + 一段内容(防止极端内容把另一块压成一条线)。 */
- min-height: calc(72 * var(--u, 1px));
- flex-direction: column;
- min-width: 0;
- min-height: 0;
- border: 1px solid var(--panel-line, rgba(1, 171, 234, 0.35));
- background: linear-gradient(
- 180deg,
- var(--panel-top, rgba(2, 9, 27, 0.28)) 0%,
- var(--panel-mid, rgba(0, 53, 156, 0.2)) 26%,
- var(--panel-bot, rgba(0, 15, 52, 0.38)) 100%
- );
- /* 2026-10-22 维护者要求磨砂稍微增强:12px → 16px(面板调透后保住文字可读性)。 */
- backdrop-filter: blur(16px) saturate(115%);
- -webkit-backdrop-filter: blur(16px) saturate(115%);
- box-shadow: inset 0 1px 0 rgba(1, 171, 234, 0.14), var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
- }
- .cockpit-panel.is-collapsed {
- flex: 0 0 auto;
- /* 收起态只占标题栏高度,下限不适用。 */
- min-height: 0;
- flex-basis: auto;
- }
- /* 卡片密度(2026-10-23):面板内边距与块间距压矮一档,好让四块内容在一屏内看完、
- 不依赖滚动条。压缩的是**呼吸感**,不是字号 —— 读数与标签一档不降。 */
- .cockpit-panel__body {
- --panel-density-gap: calc(2 * var(--u, 1px));
- }
- .cockpit-panel::before {
- content: '';
- position: absolute;
- inset: -1px;
- z-index: 3;
- pointer-events: none;
- /* 批次 5:四角几何白母版经 mask 染 --panel-line-hi(色值单一来源);几何仍代码化。
- 用 mask 简写(不是 mask-image):position/size/repeat 层语法只属于简写,
- 写进 mask-image 会被浏览器整条丢弃(四角消失)。同 CockpitIcon 的用法。 */
- background-color: var(--panel-line-hi, #01abea);
- -webkit-mask:
- var(--corner-tl) left top / 15px 15px no-repeat,
- var(--corner-tr) right top / 15px 15px no-repeat,
- var(--corner-bl) left bottom / 15px 15px no-repeat,
- var(--corner-br) right bottom / 15px 15px no-repeat;
- mask:
- var(--corner-tl) left top / 15px 15px no-repeat,
- var(--corner-tr) right top / 15px 15px no-repeat,
- var(--corner-bl) left bottom / 15px 15px no-repeat,
- var(--corner-br) right bottom / 15px 15px no-repeat;
- filter: drop-shadow(0 0 4px rgba(1, 171, 234, 0.5));
- }
- .cockpit-panel__head {
- position: relative;
- z-index: 2;
- display: flex;
- align-items: center;
- gap: calc(10 * var(--u, 1px));
- width: 100%;
- min-height: calc(40 * var(--u, 1px));
- padding: 0 calc(12 * var(--u, 1px)) 0 calc(8 * var(--u, 1px));
- border: 0;
- /* 标题栏**自己不画背景、也不画线**(票 11 二轮真正修好这条的改动)。
- 根因不是"两条线",而是**背景带在 button 右端就结束了**:状态光点是 button 的
- 兄弟节点,head 的渐变背景(… 42%, transparent 78%)到 78% 就透明,
- 于是从状态光点往左出现一条明显的"带子断口" —— 肉眼看就是标题与状态光点被分割。
- 所以背景与分隔线都搬到 .cockpit-panel__heading(横贯 head + state 整条)。 */
- background: transparent;
- font: inherit;
- text-align: left;
- cursor: pointer;
- }
- /* hover 反馈画在 heading 上(不是 head):head 只覆盖按钮那一段,
- 在 head 上画高亮会在状态光点左侧又切一刀 —— 与要修的那个 bug 是同一个形状。 */
- .cockpit-panel__head:focus-visible {
- outline: 1px solid var(--cyan, #01abea);
- outline-offset: -2px;
- }
- .cockpit-panel.is-collapsed .cockpit-panel__heading::after {
- display: none;
- }
- /* 标题:左侧一段青色竖条 + 辉光,与原型 .panel-head h2 一致 */
- .cockpit-panel__title {
- position: relative;
- flex: none;
- /* 批次 1:标题改为 inline-flex 以容纳面板图标(竖条仍是 ::before 绝对定位,不受影响)。 */
- display: inline-flex;
- align-items: center;
- gap: calc(5 * var(--u, 1px));
- padding-left: calc(10 * var(--u, 1px));
- color: var(--text);
- /* 2026-10-12:走查反馈偏小,由 14.5 上调到 15.5,与 tokens 的 --fs-title 同步。 */
- font-size: var(--fs-title, 15.5px);
- font-weight: 700;
- letter-spacing: 0.1em;
- white-space: nowrap;
- text-shadow: 0 0 12px rgba(1, 171, 234, 0.6);
- }
- /* 面板图标:单独一档弱辉光,避免继承标题的强发光把小图标糊成一团。 */
- .cockpit-panel__icon {
- flex: none;
- text-shadow: none;
- filter: drop-shadow(0 0 4px rgba(1, 171, 234, 0.45));
- }
- .cockpit-panel__title::before {
- content: '';
- position: absolute;
- left: 0;
- top: 50%;
- width: calc(4 * var(--u, 1px));
- height: calc(15 * var(--u, 1px));
- transform: translateY(-50%);
- background: linear-gradient(180deg, var(--cyan-hi), var(--cyan-d, #009de9));
- box-shadow: 0 0 10px var(--cyan, #01abea);
- clip-path: polygon(0 0, 100% 3px, 100% 100%, 0 calc(100% - 3px));
- }
- .cockpit-panel__meta {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- color: var(--muted, #5680b0);
- font-size: var(--fs-caption, 11.5px);
- letter-spacing: 0.04em;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- .cockpit-panel__meta.is-end {
- /* 规格 §6.1:overview 四块的小字是"右上角",贴右显示;仍与折叠符号留出间距。 */
- padding-right: calc(6 * var(--u, 1px));
- text-align: right;
- }
- .cockpit-panel__meta::before {
- content: '▸ ';
- color: rgba(1, 171, 234, 0.7);
- }
- .cockpit-panel__mark {
- flex: none;
- width: calc(16 * var(--u, 1px));
- color: var(--cyan, #01abea);
- font-size: var(--fs-title, 15.5px);
- line-height: 1;
- text-align: center;
- text-shadow: 0 0 10px rgba(1, 171, 234, 0.7);
- }
- .cockpit-panel__body {
- position: relative;
- z-index: 1;
- flex: 1 1 auto;
- min-height: 0;
- /* 2026-10-23 压矮:10u→7u 上下、11u→10u 左右(四块要在一屏内看完)。 */
- padding: calc(7 * var(--u, 1px)) calc(10 * var(--u, 1px));
- /* 2026-10-13 维护者要求:严格只竖向滚动,横向滚动条不出现。
- 横向裁切风险由内容侧防:面板内所有网格列都有 minmax(0,·),长词/编码走
- overflow-wrap(见 .panel-row dd 与 .panel-item 系列的 min-width:0)。 */
- overflow-y: auto;
- overflow-x: hidden;
- }
- /* 内容包裹层:高度=内容自然高度(不被 flex 拉伸),供 ResizeObserver 测量。
- 量它就是量"这块面板要多少空间",权重由测量值给(见 script 里的 measureContent)。 */
- .cockpit-panel__content {
- display: block;
- min-width: 0;
- }
- /* 标题行容器:**唯一**画背景带与分隔线的地方(head 自身透明)。
- 背景带必须覆盖 head + 状态光点**整条**,否则在状态光点左侧会出现断口 ——
- 那正是维护者两次指出的"标题与右侧状态光点之间存在明显分割"。 */
- .cockpit-panel__heading {
- position: relative;
- display: flex;
- align-items: center;
- flex: none;
- min-width: 0;
- border-bottom: 1px solid transparent;
- background:
- linear-gradient(
- 90deg,
- var(--panel-mid, rgba(0, 53, 156, 0.2)) 0%,
- var(--panel-bot, rgba(0, 15, 52, 0.38)) 46%,
- rgba(0, 15, 52, 0.28) 100%
- ),
- linear-gradient(180deg, rgba(1, 171, 234, 0.1), transparent);
- }
- /* hover 高亮同样横贯 head + 状态光点整条(与背景带的覆盖范围一致)。 */
- .cockpit-panel__heading:hover {
- background:
- linear-gradient(90deg, rgba(1, 171, 234, 0.32) 0%, rgba(0, 15, 52, 0.36) 46%, rgba(0, 15, 52, 0.3) 100%),
- linear-gradient(180deg, rgba(1, 171, 234, 0.14), transparent);
- }
- .cockpit-panel__heading::after {
- content: '';
- position: absolute;
- left: 0;
- right: 0;
- bottom: -1px;
- height: 1px;
- /* 一条完整的分割线:从标题左侧竖条起(6%)一直拉到面板右缘,
- **不在状态光点之前收淡** —— 提前收淡正是"看起来被切开"的另一半原因。 */
- background: linear-gradient(
- 90deg,
- transparent 0%,
- var(--panel-line-hi, #01abea) 6%,
- rgba(1, 171, 234, 0.85) 60%,
- rgba(1, 171, 234, 0.62) 100%
- );
- opacity: 0.65;
- pointer-events: none;
- }
- .cockpit-panel__heading .cockpit-panel__head {
- flex: 1;
- min-width: 0;
- width: auto;
- border-bottom: 0;
- }
- /* 状态光点:与标题栏共用同一条背景带(见 .cockpit-panel__heading)。
- 右侧内边距与 head 的右内边距一致,让背景带两侧留白对称 ——
- 之前它靠 margin-right 顶开,导致背景带在它左侧断开。 */
- .cockpit-panel__state {
- margin-right: calc(12 * var(--u, 1px));
- position: relative;
- z-index: 2;
- }
- </style>
|