PanelBlocks.vue 96 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140
  1. <!--
  2. 面板块的**共享渲染层**(票 17 建立,票 18 改名并解除模式耦合)。
  3. 为什么所有面板共用一个组件(而不是每个模式一套):它们的形状是同一件事 ——
  4. 几组 KPI + 若干分布/清单/趋势,差异只在字段与文案,而那些**都已经在各模式的
  5. `domain/*Panels.js` 里翻好了**。组件因此只按 `block.kind` 分派画法,不认识任何后端字段。
  6. 票 18 的两处改动(都是为了让票 19/20 能接进来,而不是再长一套):
  7. 1. **改名 `InfrastructurePanel` → `PanelBlocks`**:旧名字带着「基础设施模式」,
  8. 而它从建立起就是通用块渲染(当时只有那一个模式在用)。名字与职责错位会让下一个模式
  9. 误以为要另写一个组件 —— 那正是票 17 明确反对的「下游三票再长出十二份实现」。
  10. 2. **吃算好的 `view` prop,不再按 `panelId` 自己找视图**:分发收进
  11. `domain/panelViews.js` 的注册表。这样组件与任何模式解耦(它甚至不知道共有几个模式),
  12. 而「哪个面板有哪些块」各自归本模式的 domain 模块。
  13. 三条纪律在这里的落点(票 17 立,本票一字不改):
  14. · 缺值显示占位符(`—`,来自 domain,组件只判断"是不是占位符"来降亮度);
  15. · 面板自己的成败由 `SectionStateTag` 说(色键与文案只来自 domain/sectionState.js);
  16. · 接口词汇不上大屏 —— 组件里不出现任何后端字段名或原因码(票 05 的静态守卫盯着这条)。
  17. `data-*` 是走查断言面:`data-panel-blocks`/`data-panel-id` 定位面板,
  18. `data-locate-point` 是可定位对象,`data-ledger-card` 是台账卡片,
  19. `data-pie-*` 是状态分布饼图(票 14:扇区/图例行/选中态;面板不再自带筛选入口),
  20. `data-panel-block` 是块类型,
  21. 票 56(二/三轮):顶部口径说明统一折叠进 `data-panel-summary-toggle` /
  22. `data-panel-summary-details`,每条**保留自己原有的断言面**(折叠只是 `display:none`,
  23. 文本仍在 DOM 里):`data-panel-gap` 契约缺口、`data-panel-note` 面板口径说明、
  24. `data-panel-link-note` 联动出口、`data-panel-excluded-note` 口径剔除、
  25. `data-panel-industry` / `data-panel-type-note` / `data-panel-event-note` /
  26. `data-panel-range-note` / `data-panel-offline-note` / `data-panel-page-note` /
  27. `data-locate-note`,另带 `data-summary="<键>"` 供按条目定位。
  28. -->
  29. <template>
  30. <div ref="rootRef" class="panel-blocks" :data-panel-blocks="view.name" :data-panel-id="view.name">
  31. <!-- 面板级状态(色键与文案只来自 domain/sectionState.js)。票 56 二轮:整条收成折叠入口。 -->
  32. <header v-if="summaryItems.length" class="panel-blocks__head">
  33. <!--
  34. 票 56(第二轮,维护者 2026-10-17):**所有面板**的顶部摘要说明统一收成
  35. 一个紧凑入口,且**只有点击**才展开/收起(撤销第一轮的 hover 自动展开——
  36. 大屏上鼠标掠过就弹开,等于没有折叠)。键盘与触屏走原生 button:
  37. Enter / Space / click 都会触发同一个 toggle,不必另写 keydown。
  38. 每条摘要**保留自己原有的 `data-*` 断言面**(折叠时 `v-show` 只是隐藏,
  39. 文本仍在 DOM 里),走查脚本读到的是同一句 domain 文案。
  40. -->
  41. <span class="panel-summary-disclosure">
  42. <button
  43. type="button"
  44. class="panel-summary-disclosure__toggle"
  45. :aria-expanded="summaryOpen"
  46. :aria-controls="summaryDetailsId"
  47. :aria-label="`${view.label || '面板'}摘要,${summaryOpen ? '收起' : '展开'}完整说明`"
  48. data-panel-summary-toggle
  49. @click="summaryOpen = !summaryOpen"
  50. >
  51. <span>{{ view.label || '面板' }}摘要</span><b>{{ summaryStatus }}</b><i aria-hidden="true">{{ summaryOpen ? '收起' : '展开' }}</i>
  52. </button>
  53. <span v-show="summaryOpen" :id="summaryDetailsId" class="panel-summary-disclosure__details" data-panel-summary-details>
  54. <span
  55. v-for="item in summaryItems"
  56. :key="item.key"
  57. :class="item.isGap ? 'panel-blocks__gap' : 'panel-blocks__note'"
  58. v-bind="item.attrs"
  59. >{{ item.text }}</span>
  60. </span>
  61. </span>
  62. </header>
  63. <!-- 还没取过数:说"正在取数",不说"没有数据"(那是在报告一件还没问过的事) -->
  64. <p v-if="pending" class="panel-blocks__placeholder" data-panel-pending>{{ view.placeholder }}</p>
  65. <!-- 有内容:按块分派 -->
  66. <template v-else-if="view.hasContent">
  67. <section
  68. v-for="(block, index) in view.blocks"
  69. :key="`${block.kind}-${index}`"
  70. class="panel-block"
  71. :data-panel-block="block.kind"
  72. >
  73. <EllipsisText v-if="block.label" tag="h4" class="panel-block__title" :text="block.label" />
  74. <!-- KPI:等宽数字 + 单位,缺值降亮度显示占位符。
  75. 2026-10-13 维护者要求:每项读数左侧带语义图标(domain 的 KPI_ICONS
  76. 按 key 映射,未命中不画);图标 aria-hidden 不参与命名。 -->
  77. <div
  78. v-if="block.kind === 'kpi'"
  79. class="panel-kpis"
  80. :class="{ 'panel-kpis--two-by-two': block.layout === 'two-by-two' }"
  81. data-kpi-row
  82. >
  83. <!-- 票 38(第 4 条):基础档案的「设施总数 + 管线里程」两项落回同一张
  84. auto-fit 网格自然同行。
  85. 票 38 第二轮(反馈第 1 条):整行独占改由 domain 的通用 `wide` 标志声明
  86. (概览基础设施的管线里程独占一行排版更好看),渲染层不认识任何 key。 -->
  87. <div
  88. v-for="item in block.items"
  89. :key="item.key"
  90. class="panel-kpi"
  91. :class="{ 'panel-kpi--wide': item.wide === true }"
  92. :data-kpi="item.key"
  93. >
  94. <!-- 票 37 走查扩展:读数图标走 mask 染语义色(KPI_ICON_COLORS,未命中主蓝)。
  95. 色画在容器上(currentColor),图标本体是白剪影。 -->
  96. <span
  97. v-if="item.icon"
  98. class="panel-kpi__icon"
  99. :style="{ color: item.iconColor || 'var(--cyan, #01abea)' }"
  100. >
  101. <CockpitIcon :name="`kpi${item.icon[0].toUpperCase()}${item.icon.slice(1)}`" size="calc(14 * var(--u, 1px))" />
  102. </span>
  103. <EllipsisText class="panel-kpi__label" :text="item.label" />
  104. <!--
  105. 票 20:带 `link` 的读数可点(联动出口)。组件只回答"这一项被点了",
  106. `link` 是什么、点了去哪,全由 domain 给、外壳翻 —— 组件仍不认识任何 panelId。
  107. 票 37:读数与标签一样走**共用省略方法**(越界省略 + hover 读全);
  108. 动作提示用 `hint` 附在完整原文之后,原文因此不会因为要写提示而丢。
  109. -->
  110. <EllipsisText
  111. v-if="item.link"
  112. tag="button"
  113. type="button"
  114. class="panel-kpi__value panel-kpi__value--link"
  115. :class="{ 'is-muted': isPlaceholder(item.value) }"
  116. :data-kpi-link="item.link"
  117. :text="kpiReading(item)"
  118. :hint="`${item.label}:${item.linkHint || '查看明细'}`"
  119. @click="emit('panel-action', { kind: 'link', key: item.link })"
  120. >
  121. {{ item.value }}<em v-if="item.unit && !isPlaceholder(item.value)"> {{ item.unit }}</em>
  122. </EllipsisText>
  123. <EllipsisText
  124. v-else
  125. tag="b"
  126. class="panel-kpi__value"
  127. :class="{ 'is-muted': isPlaceholder(item.value) }"
  128. :text="kpiReading(item)"
  129. >
  130. {{ item.value }}<em v-if="item.unit && !isPlaceholder(item.value)"> {{ item.unit }}</em>
  131. </EllipsisText>
  132. <EllipsisText v-if="item.hint" tag="small" class="panel-kpi__hint" :text="item.hint" />
  133. </div>
  134. </div>
  135. <!--
  136. 分布:条长按最大项归一(相对长度,不冒充占比)。
  137. 票 20:带 `action` 的块整行可点(如行业构成 → 写全局行业筛选);
  138. 组件只转发"哪一行被点了",不解释这个动作的语义。
  139. 票 37:**只有带得出 `actionKey` 的行**才是按钮 —— 展示名不是后端筛选码,
  140. 拿它兜底去发参数就是"拿脏值换后端 400";`row.selected` 画选中态(再点即取消)。
  141. 2026-10-13 维护者要求:条形按行语义着色(行业行=行业色、状态行=语义色、
  142. 其余=图表色序),一眼对应类别;色值仍只在 tokens.css 声明一份。
  143. -->
  144. <div v-else-if="block.kind === 'bars'" class="panel-bars" :class="{ 'panel-bars--clickable': block.action }" data-bar-list>
  145. <component
  146. :is="block.action && row.actionKey ? 'button' : 'div'"
  147. v-for="row in block.rows"
  148. :key="row.key"
  149. class="panel-bar"
  150. :class="{ 'panel-bar--button': block.action && row.actionKey, 'is-active': row.selected }"
  151. :type="block.action && row.actionKey ? 'button' : null"
  152. :data-bar="row.key"
  153. :data-bar-action="block.action || null"
  154. :data-bar-tone="row.tone || null"
  155. :data-bar-selected="row.selected ? 'yes' : 'no'"
  156. :title="block.action && row.actionKey ? `把「${row.label}」设为全局筛选条件` : null"
  157. @click="block.action && row.actionKey ? emit('panel-action', { kind: block.action, key: row.actionKey }) : null"
  158. @mouseenter="showChartTip($event, row.label, row.hint || row.count, barColor(row.tone))"
  159. @mouseleave="hideChartTip"
  160. >
  161. <EllipsisText class="panel-bar__label" :text="row.label" />
  162. <i class="panel-bar__track"><i class="panel-bar__fill" :style="{ width: `${Math.round(row.ratio * 100)}%`, background: barColor(row.tone) }"></i></i>
  163. <span class="panel-bar__count" data-bar-count>{{ row.count }}</span>
  164. </component>
  165. <p v-if="!block.rows.length" class="panel-block__empty" data-bar-empty>该维度当前没有记录</p>
  166. </div>
  167. <!--
  168. 可切换维度的分类柱状图(票 38 · 管理信息「片区 × 行业」)。
  169. 维度切换是**纯展示状态**(组件本地):两个维度的行都由 domain 预先聚合好,
  170. 点标签只换 rows,不回后端、不进全局筛选——它回答「同一份计数换哪个维度看」,
  171. 不是筛选条件(与条形图可点行的 `panel-action` 接缝刻意分开,互不冒充)。
  172. 柱/条/读数与 `.panel-bar` 同一套画法与色键(barColor + 语义 tone),
  173. `data-dimbar-*` 与 `data-dim-tab-*` 是走查断言面。
  174. -->
  175. <div v-else-if="block.kind === 'dimbars'" class="panel-dimbars" data-dimbars-list>
  176. <div class="panel-dimbars__tabs" role="group" :aria-label="`${block.label} · 维度切换`">
  177. <button
  178. v-for="dim in block.dimensions"
  179. :key="dim.key"
  180. type="button"
  181. class="panel-dimbars__tab"
  182. :class="{ 'is-active': dim.key === activeDimension(block) }"
  183. :aria-pressed="dim.key === activeDimension(block)"
  184. :data-dim-tab="dim.key"
  185. @click="setDimension(block, dim.key)"
  186. >
  187. {{ dim.label }}
  188. </button>
  189. </div>
  190. <div v-if="currentDim(block)" class="panel-bars panel-dimbars__bars">
  191. <div
  192. v-for="row in currentDim(block).rows"
  193. :key="row.key"
  194. class="panel-bar"
  195. :data-dimbar="row.key"
  196. :data-dim-tone="row.tone || null"
  197. @mouseenter="showChartTip($event, row.label, row.hint || `${row.count}${block.unit}`, barColor(row.tone))"
  198. @mouseleave="hideChartTip"
  199. >
  200. <EllipsisText class="panel-bar__label" :text="row.label" />
  201. <i class="panel-bar__track"><i class="panel-bar__fill" :style="{ width: `${Math.round(row.ratio * 100)}%`, background: barColor(row.tone) }"></i></i>
  202. <span class="panel-bar__count" data-dimbar-count>{{ row.count }}</span>
  203. </div>
  204. <p v-if="!currentDim(block).rows.length" class="panel-block__empty" data-dimbar-empty>{{ block.emptyText }}</p>
  205. </div>
  206. <p v-else class="panel-block__empty" data-dimbar-empty>{{ block.emptyText }}</p>
  207. </div>
  208. <!--
  209. 趋势:分桶序列(桶粒度与顺序由后端给,本层不重采样)。
  210. 2026-10-13 维护者图表选型:时间趋势优先折线;<2 点或柱形态仍画柱列。
  211. 折线版式(维护者第三轮:「太丑、线与数值对不上」):
  212. · Y 轴三档刻度(0/半程/峰值)+ 水平网格线 —— 数值与高度有坐标对应;
  213. · 面积渐变(主色向下渐隐)+ 平滑折线 + 正圆数据点(HTML 叠层,不被拉伸);
  214. · 每点顶标注数值(≤8 点全标;多时标峰值),悬停 title 兜底;
  215. · X 轴保留桶标签行。读数接缝(data-trend-*)不变。
  216. -->
  217. <div v-else-if="block.kind === 'trend'" class="panel-trend" :class="{ 'panel-trend--line': block.chart === 'line' && block.linePoints.length >= 2 }" data-trend-list>
  218. <template v-if="block.chart === 'line' && block.linePoints.length >= 2">
  219. <div class="panel-trend__chart" data-trend-chart>
  220. <!-- Y 轴刻度 + 网格线(刻度标签落在左侧留出的槽里) -->
  221. <div
  222. v-for="tick in block.yTicks"
  223. :key="`grid-${tick.pct}`"
  224. class="panel-trend__grid"
  225. :class="{ 'is-top': tick.pct === 100 }"
  226. :style="{ bottom: `${tick.pct}%` }"
  227. >
  228. <b class="panel-trend__ytick" data-trend-ytick>{{ tick.value }}</b>
  229. </div>
  230. <!-- 绘图区:SVG 与数据点/标签共用同一坐标基准(百分比都相对它) -->
  231. <div class="panel-trend__plot">
  232. <svg class="panel-trend__line" viewBox="0 0 100 100" preserveAspectRatio="none" role="img" :aria-label="block.label">
  233. <polygon class="panel-trend__area" :points="block.areaPoints" />
  234. <polyline
  235. class="panel-trend__line-path"
  236. :points="block.linePoints.map((p) => `${p.x},${p.y}`).join(' ')"
  237. fill="none"
  238. />
  239. </svg>
  240. <div
  241. v-for="p in block.linePoints"
  242. :key="p.key"
  243. class="panel-trend__node"
  244. :class="{ 'is-peak': !block.showInlineLabels && p.annotate }"
  245. :style="{ left: `${p.xPct}%`, bottom: `${p.yPct}%` }"
  246. :data-trend="p.key"
  247. :aria-label="`${p.label}:${p.displayText || p.count}`"
  248. @mouseenter="showChartTip($event, p.label, p.displayText || p.count, 'var(--cyan-hi, #00b8ff)')"
  249. @mouseleave="hideChartTip"
  250. >
  251. <b v-if="p.annotate" class="panel-trend__vlabel" data-trend-count>{{ p.displayText || p.count }}</b>
  252. <i class="panel-trend__dot" aria-hidden="true"></i>
  253. </div>
  254. </div>
  255. </div>
  256. <div class="panel-trend__axis" data-trend-axis>
  257. <EllipsisText
  258. v-for="(point, i) in block.points"
  259. :key="point.key"
  260. tag="span"
  261. class="panel-trend__label"
  262. :style="{ left: `${block.linePoints[i] ? block.linePoints[i].xPct : 0}%` }"
  263. :text="block.axisIndices.includes(i) ? point.label : ''"
  264. />
  265. </div>
  266. </template>
  267. <template v-else>
  268. <div
  269. v-for="point in block.points"
  270. :key="point.key"
  271. class="panel-trend__point"
  272. :data-trend="point.key"
  273. @mouseenter="showChartTip($event, point.label, point.displayText || point.count, 'var(--cyan, #01abea)')"
  274. @mouseleave="hideChartTip"
  275. >
  276. <i class="panel-trend__bar" :style="{ height: `${Math.max(2, Math.round(point.ratio * 100))}%` }"></i>
  277. <span class="panel-trend__count" data-trend-count>{{ point.displayText || point.count }}</span>
  278. <EllipsisText class="panel-trend__label" :text="point.label" />
  279. </div>
  280. </template>
  281. <p v-if="!block.points.length" class="panel-block__empty" data-trend-empty>该窗口没有事件记录</p>
  282. </div>
  283. <!--
  284. 百分比堆叠条(2026-10-13 维护者图表选型:多类别占比)。
  285. 一根条按占比分段(domain 算好 start/宽度),下方图例逐行列出计数——
  286. 0 计数的行保留图例(含义可查),不占条宽。
  287. 2026-10-13 维护者反馈:分段按语义 tone 着色(工单状态=语义色);
  288. 同语义多行(两个待办态都是 lv3)用 shade 深浅区分。
  289. 票 37 走查扩展:分段悬停**放大 + 详情浮层**(如「蓝色:待派单:1」);
  290. 分段是绝对定位的窄条,放大用 scaleY+Y 向拉伸、宽度保持(不挤邻居)。
  291. 票 43:段间小间隔=`left`/`width` 各自独立取整露出的底色缝(绘制缺陷,
  292. 非语义边界)。边界改由 domain 的接力取整(`startPct`/`widthPct`,
  293. 每段起点=上一段终点)给出整数百分点,连续由构造保证。
  294. -->
  295. <div v-else-if="block.kind === 'stacked'" class="panel-stacked" data-stacked-list>
  296. <div class="panel-stacked__bar" role="img" :aria-label="`${block.label}(合计 ${block.total})`">
  297. <i
  298. v-for="row in block.rows"
  299. :key="row.key"
  300. v-show="row.ratio > 0"
  301. class="panel-stacked__seg"
  302. :data-stacked-seg="row.key"
  303. :style="{
  304. left: `${row.startPct}%`,
  305. width: `${row.widthPct}%`,
  306. '--seg-shade': row.shade,
  307. '--seg-color': stackedColor(row),
  308. }"
  309. :title="`${row.label} ${row.count}`"
  310. @mouseenter="showChartTip($event, row.label, row.count, stackedColor(row))"
  311. @mouseleave="hideChartTip"
  312. ></i>
  313. </div>
  314. <ul class="panel-stacked__legend" data-stacked-legend>
  315. <li
  316. v-for="row in block.rows"
  317. :key="row.key"
  318. class="panel-stacked__row"
  319. :data-stacked="row.key"
  320. @mouseenter="showChartTip($event, row.label, row.count, stackedColor(row))"
  321. @mouseleave="hideChartTip"
  322. >
  323. <i class="panel-stacked__swatch" :style="{ '--seg-color': stackedColor(row) }" aria-hidden="true"></i>
  324. <EllipsisText class="panel-stacked__label" :text="row.label" />
  325. <b class="panel-stacked__count" data-stacked-count>{{ row.count }}</b>
  326. </li>
  327. </ul>
  328. </div>
  329. <!--
  330. 环形图(2026-10-13 维护者反馈:条形图太多易视觉疲劳——「类别占比、类别少」
  331. 场景改用环形图,中心读数合计)。扇区与图例按语义 tone 着色(行业行=行业色)。
  332. 带 `action` 时图例行可点(联动写全局筛选,与条形图同一条接缝)。
  333. 与上方饼图的分工:饼图是点位状态分布专用(选中高亮 + 地图图例同源)。
  334. 票 43(重开)交互修复:可见环带太窄(42/26),真实指针落在中心孔或
  335. 环带外都不触发——「只有悬停图例才出详情」的根因。改画**透明整楔命中区**
  336. (`hitPath`,内半径 0)承接悬停/聚焦,热区与视觉扇区同角度同边界;
  337. 扇区 ⇄ 图例**双向联动**(任一侧悬停/聚焦,两侧同时亮 + 同一个共享浮层),
  338. 手感对齐已验收横条统计(整行即热区)。键盘焦点在命中楔形上,
  339. 可访问名称=「类别:数量」;中心合计 `pointer-events:none` 让位给命中区。
  340. -->
  341. <div v-else-if="block.kind === 'donut'" class="panel-donut" data-donut-list>
  342. <template v-if="block.total > 0">
  343. <div class="panel-donut__wrap">
  344. <svg class="panel-donut__chart" viewBox="0 0 100 100" role="group" :aria-label="block.label">
  345. <path
  346. v-for="slice in block.slices"
  347. :key="slice.key"
  348. class="panel-donut__slice"
  349. :class="{ 'is-hovered': isChartHovered(chartScope(index, block), slice.key) }"
  350. :d="slice.path"
  351. :fill="barColor(slice.tone)"
  352. :data-donut-slice="slice.key"
  353. aria-hidden="true"
  354. ></path>
  355. <path
  356. v-for="slice in block.slices"
  357. :key="`hit-${slice.key}`"
  358. class="panel-donut__hit"
  359. :d="slice.hitPath"
  360. :data-donut-hit="slice.key"
  361. tabindex="0"
  362. role="img"
  363. :aria-label="`${slice.label}:${slice.count}`"
  364. @mouseenter="showChartTip($event, slice.label, slice.count, barColor(slice.tone)); setChartHover(chartScope(index, block), slice.key)"
  365. @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
  366. @focus="showChartTip($event, slice.label, slice.count, barColor(slice.tone)); setChartHover(chartScope(index, block), slice.key)"
  367. @blur="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
  368. ></path>
  369. </svg>
  370. <b class="panel-donut__total" data-donut-total>{{ block.total }}</b>
  371. </div>
  372. <ul class="panel-donut__legend" data-donut-legend>
  373. <component
  374. :is="block.action && row.actionKey ? 'button' : 'li'"
  375. v-for="row in block.rows"
  376. :key="row.key"
  377. class="panel-donut__row"
  378. :class="{ 'panel-donut__row--button': block.action && row.actionKey, 'is-hovered': isChartHovered(chartScope(index, block), row.key) }"
  379. :type="block.action && row.actionKey ? 'button' : null"
  380. :data-donut-row="row.key"
  381. :data-donut-action="block.action || null"
  382. :title="block.action && row.actionKey ? `把「${row.label}」设为全局筛选条件` : null"
  383. @click="block.action && row.actionKey ? emit('panel-action', { kind: block.action, key: row.actionKey }) : null"
  384. @mouseenter="showChartTip($event, row.label, row.count, barColor(row.tone)); setChartHover(chartScope(index, block), row.key)"
  385. @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
  386. >
  387. <i class="panel-donut__swatch" :style="{ background: barColor(row.tone) }" aria-hidden="true"></i>
  388. <EllipsisText class="panel-donut__label" :text="row.label" />
  389. <b class="panel-donut__count" data-donut-count>{{ row.count }}</b>
  390. </component>
  391. </ul>
  392. </template>
  393. <p v-else class="panel-block__empty" data-donut-empty>该维度当前没有记录</p>
  394. </div>
  395. <!--
  396. 票 14:状态分布**饼图**(决策 02 的 Q12/Q19)。消费全局未筛分布(与顶部
  397. 状态栏共用同一份),只**高亮当前选中**——它不是筛选入口,不可点;
  398. 全局条件唯一可操作面在地图顶部状态栏。
  399. 票 43(重开):与环形图同一修复——透明整楔命中区(`hitPath`)承接
  400. 悬停/聚焦(可见环带太窄是「只有图例触发」的根因),扇区 ⇄ 图例双向
  401. 联动,浮层与放大两侧同步;外层 svg `role="group"` 让名称可达。
  402. -->
  403. <div v-else-if="block.kind === 'pie'" class="panel-pie" data-pie>
  404. <template v-if="block.ready && block.total > 0">
  405. <svg class="panel-pie__chart" viewBox="0 0 100 100" role="group" :aria-label="block.label">
  406. <path
  407. v-for="slice in block.slices"
  408. :key="slice.key"
  409. class="panel-pie__slice"
  410. :class="{ 'is-dim': !slice.selected && block.hasSelection, 'is-hovered': isChartHovered(chartScope(index, block), slice.key) }"
  411. :d="slice.path"
  412. :fill="pieToneColor(slice.tone, slice.key)"
  413. :data-pie-slice="slice.key"
  414. :data-pie-selected="slice.selected ? 'yes' : 'no'"
  415. aria-hidden="true"
  416. ></path>
  417. <path
  418. v-for="slice in block.slices"
  419. :key="`hit-${slice.key}`"
  420. class="panel-pie__hit"
  421. :d="slice.hitPath"
  422. :data-pie-hit="slice.key"
  423. tabindex="0"
  424. role="img"
  425. :aria-label="`${slice.label}:${slice.count}`"
  426. @mouseenter="showChartTip($event, slice.label, slice.count, pieToneColor(slice.tone, slice.key)); setChartHover(chartScope(index, block), slice.key)"
  427. @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
  428. @focus="showChartTip($event, slice.label, slice.count, pieToneColor(slice.tone, slice.key)); setChartHover(chartScope(index, block), slice.key)"
  429. @blur="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
  430. ></path>
  431. </svg>
  432. <ul class="panel-pie__legend" data-pie-legend>
  433. <li
  434. v-for="row in block.rows"
  435. :key="row.key"
  436. class="panel-pie__row"
  437. :class="{ 'is-selected': row.selected, 'is-hovered': isChartHovered(chartScope(index, block), row.key) }"
  438. :data-pie-row="row.key"
  439. :data-pie-selected="row.selected ? 'yes' : 'no'"
  440. @mouseenter="showChartTip($event, row.label, row.count, pieToneColor(row.tone, row.key)); setChartHover(chartScope(index, block), row.key)"
  441. @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
  442. >
  443. <!--
  444. 票 39 二轮(维护者走查反馈):图例行与一张图顶部状态栏**同一形态**——
  445. `legendIconFor` 单源图标 + 文字(StatusTag 传 icon 就只画图标、不画形状,
  446. 票 46 返工定稿的同一条接缝)。颜色不再走 pieToneColor 这份第二映射:
  447. StatusTag 的 tone 类解析出完全相同的 token,顶部图例也是这么取色的。
  448. -->
  449. <StatusTag :tone="row.tone" :label="row.label" :icon="row.icon || ''" />
  450. <b class="panel-pie__count" data-pie-count>{{ row.count }}</b>
  451. </li>
  452. </ul>
  453. </template>
  454. <!-- 全局分布未取到:如实说明(不伪造全零饼图,票 05 的纪律)。 -->
  455. <p v-else-if="block.note" class="panel-block__empty" data-pie-note>{{ block.note }}</p>
  456. <!-- 取到了但全为 0:这是真实值,不是缺失。 -->
  457. <p v-else class="panel-block__empty" data-pie-empty>当前范围内没有监测点与未关联设备</p>
  458. </div>
  459. <!-- 键值行 -->
  460. <dl v-else-if="block.kind === 'rows'" class="panel-rows" data-row-list>
  461. <div v-for="row in block.rows" :key="row.key" class="panel-row" :data-row="row.key">
  462. <dt>{{ row.label }}</dt>
  463. <dd :class="{ 'is-muted': isPlaceholder(row.value) }">{{ row.value }}</dd>
  464. </div>
  465. </dl>
  466. <!--
  467. 条目清单:两种形态共用这一块 —— 普通条目(带可定位点位)与**台账卡片**
  468. (整张卡可点,走与地图相同的两级详情)。判据是行上有没有 `code`。
  469. 票 40:清单卡升级为「事件卡片」形态(样式参考当前预警面板的事件卡,
  470. 维护者 2026-10-16:卡片高度不需要那么高,主要参考设计及交互效果)。
  471. 整卡可点:有 `code` 的卡(台账监测点)打开两级详情;没有 `code` 的卡
  472. (运行概况异常设备)走既有 `locate` 接缝 —— 诚实的三种「点不动」反馈
  473. + 设备级详情,不新造第二条路径。卡内按钮自己 `.stop`,不二次触发。
  474. 票 64:行数由 `windowOf(block)` 给 —— domain 声明 `paginate` 的块
  475. 每页 ≤10(统一分页规范),其余块交出原数组,画法与行为一字不改。
  476. -->
  477. <div v-else-if="block.kind === 'list'" class="panel-list" data-item-list>
  478. <article
  479. v-for="row in windowOf(block).rows"
  480. :key="row.key"
  481. class="panel-item"
  482. :class="{ 'panel-item--clickable': row.code || (row.points && row.points.length) }"
  483. :data-item="row.key"
  484. :data-ledger-card="row.code || null"
  485. @click="onCardClick(row, $event)"
  486. >
  487. <div class="panel-item__head">
  488. <!-- 有编码的行整张卡可点(台账);没有编码的只有点位按钮可点(票 17 的形态)。
  489. 票 37:标题与下面三行说明一样走**共用省略方法**(越界省略 + hover 读全)。 -->
  490. <EllipsisText
  491. v-if="row.code"
  492. tag="button"
  493. type="button"
  494. class="panel-item__title panel-item__title--button"
  495. :text="row.name"
  496. hint="点击打开两级详情"
  497. @click.stop="emit('open-card', row.code)"
  498. />
  499. <EllipsisText v-else class="panel-item__title" :text="row.title" />
  500. <!-- 色键必带文字(票 06:标签不能只靠颜色);文案由 domain 给 -->
  501. <StatusTag v-if="row.tone && row.toneLabel" :tone="row.tone" :label="row.toneLabel" />
  502. </div>
  503. <EllipsisText v-if="row.meta && row.meta.length" tag="p" class="panel-item__meta" :text="row.meta.join(' · ')" />
  504. <!-- data-data-time 是走查断言面(票 15 的 t15 闸门按源码钉住 `data-data-time>数据时间` 这个形状,
  505. 属性与正文必须同行);文本走插槽,title 仍拿完整原文(票 37 的共用省略方法)。 -->
  506. <EllipsisText v-if="row.dataTime" tag="p" class="panel-item__meta" :text="`数据时间 ${row.dataTime}`" data-data-time>数据时间 {{ row.dataTime }}</EllipsisText>
  507. <EllipsisText v-if="row.reason" tag="p" class="panel-item__meta panel-item__reason" data-ledger-reason :text="row.reason" />
  508. <!-- 徽标(票 18 的台账 N+1):取到才画,没取到显示"取数中/未取到",**不画 0** -->
  509. <p v-if="row.badge" class="panel-item__badge" data-ledger-badge>
  510. 设备 {{ row.badge.total }} · 报警 {{ row.badge.alarm }} · 预警 {{ row.badge.warning }} · 离线 {{ row.badge.offline }}
  511. </p>
  512. <p v-else-if="row.badgePending" class="panel-item__badge is-muted" data-ledger-badge-pending>设备统计取数中…</p>
  513. <div v-if="row.points && row.points.length" class="panel-item__points">
  514. <button
  515. v-for="point in row.points"
  516. :key="point.key"
  517. type="button"
  518. class="panel-locate"
  519. :data-locate-point="point.pointCode"
  520. :title="`在地图上定位 ${point.label || point.pointCode}`"
  521. @click.stop="emit('locate', point.pointCode)"
  522. >
  523. 定位 {{ point.pointCode }}
  524. </button>
  525. </div>
  526. </article>
  527. <!--
  528. 该段自己的成败:后端 `UNAVAILABLE`(没取到)与 `EMPTY`(确实没有)分得开。
  529. 它**优先于**下面的空列表提示,且不挂在 `v-else-if` 上 ——
  530. 原来挂在空列表之后时,两个分支的条件在本契约里互斥
  531. (有 stateText 就必然没有 rows),那个分支永远不执行,是条死代码。
  532. -->
  533. <p v-if="block.stateText" class="panel-block__empty" data-item-state>{{ block.stateText }}</p>
  534. <p v-else-if="!windowOf(block).total" class="panel-block__empty" data-item-empty>
  535. {{ block.emptyText }}
  536. </p>
  537. <!--
  538. 票 64:统一分页控件(共享 PagerNav,规范条文 2/3/4/9 + 跳页节)。
  539. 只有 domain 声明 `paginate`(前端集合分页)或 `serverPage`
  540. (服务端页码分页,行已是后端当前页)的块才画;一页不画(条文 4,
  541. 由控件自己按 pageCount>1 收口)。总数取**过滤前**集合长度或后端
  542. `pagination.total`,不由当前页反推(条文 7)。
  543. -->
  544. <PagerNav
  545. v-if="block.paginate === true || block.serverPage"
  546. :page="windowOf(block).page"
  547. :page-count="windowOf(block).pageCount"
  548. :total="windowOf(block).total"
  549. :list-name="block.label"
  550. @page="(target) => gotoListPage(block, target)"
  551. />
  552. <!--
  553. 票 61:翻页区的**如实口径说明**(多窗合并范围「已合并 N 个时间窗 ·
  554. 已加载 M 条」)。它是口径不是控件——一页不到、控件不画时也要出现,
  555. 所以不挂在 PagerNav 的 v-if 里。
  556. -->
  557. <p v-if="block.pageNote" class="panel-block__page-note" data-list-page-note>{{ block.pageNote }}</p>
  558. </div>
  559. </section>
  560. <!--
  561. 票 62:「加载下一页监测点」按钮与 `ledger-more` 接缝已退役 ——
  562. 台账装载改**自动渐进链**(与一张图点位数据同款纪律),界面只对已装载集合
  563. 做页码分页;「还有没有下一批」由页脚 `pageNote` 如实说明,不再靠一个按钮。
  564. -->
  565. <!-- 字段缺失:只说条数,路径进 tooltip(票 11 的立场) -->
  566. <p
  567. v-if="view.missing.count"
  568. class="panel-blocks__missing"
  569. data-panel-missing
  570. :data-missing-count="view.missing.count"
  571. :title="view.missing.tooltip"
  572. >
  573. {{ view.missing.text }}
  574. </p>
  575. </template>
  576. <!-- 没内容:如实说(失败/空/不可用各有措辞,来自 domain) -->
  577. <p v-else class="panel-blocks__placeholder" data-panel-placeholder>{{ view.placeholder }}</p>
  578. <!-- 图表 hover 详情浮层(票 37 走查扩展):锚在悬停元素上方,pointer-events:none 不挡交互 -->
  579. <div
  580. v-if="chartTip"
  581. class="chart-tip"
  582. :style="{ left: `${chartTip.x}px`, top: `${chartTip.y}px`, '--tip-color': chartTip.color }"
  583. data-chart-tip
  584. >
  585. <i class="chart-tip__swatch" aria-hidden="true"></i>
  586. <span class="chart-tip__label">{{ chartTip.label }}</span>
  587. <b class="chart-tip__value">{{ chartTip.value }}</b>
  588. </div>
  589. </div>
  590. </template>
  591. <script setup>
  592. import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
  593. import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
  594. import { sectionPending } from '../domain/panelBlocks.js'
  595. import { pagedRows, readListPage, resetListPages, writeListPage } from '../domain/pagination.js'
  596. import CockpitIcon from './CockpitIcon.vue'
  597. import EllipsisText from './EllipsisText.vue'
  598. import PagerNav from './PagerNav.vue'
  599. import StatusTag from './StatusTag.vue'
  600. const props = defineProps({
  601. /**
  602. * 算好的视图计划(来自 `domain/panelViews.js` 的 `panelView()`)。
  603. * 组件**不认识 panelId**:分发归注册表,画法归 `block.kind`。
  604. */
  605. view: { type: Object, required: true },
  606. })
  607. // 票 62:`ledger-more` 已退役(台账装载是自动链,界面不再有「加载下一页」入口)。
  608. const emit = defineEmits(['locate', 'open-card', 'badge-needed', 'panel-action', 'list-page'])
  609. /**
  610. * 票 40:整卡可点(交互参考「当前预警」的事件卡片)。
  611. * · 有 `code` 的卡(台账监测点)→ 打开两级详情(与标题按钮同一出口,不新造路径);
  612. * · 没有 `code` 的卡(运行概况异常设备)→ 走既有 `locate` 接缝:诚实的三种
  613. * 「点不动」反馈 + 设备级详情(`monitoringStore.locateCode` 的既有分流)。
  614. * 卡内按钮已 `.stop`,走到这里的就是卡片空白处的点击;再兜一层 closest('button')
  615. * 防将来有人忘了加 stop —— 双保险便宜,误触发的代价(飞镜头)不便宜。
  616. */
  617. function onCardClick(row, event) {
  618. if (event && event.target && typeof event.target.closest === 'function' && event.target.closest('button')) return
  619. if (row.code) {
  620. emit('open-card', row.code)
  621. return
  622. }
  623. const point = (row.points || [])[0]
  624. if (point) emit('locate', point.pointCode)
  625. }
  626. /** 还没取过数(界面说"正在取数",不说"没有数据")。 */
  627. const pending = computed(() => sectionPending(props.view.section))
  628. // 票 56(第二轮):所有面板顶部的摘要说明统一折叠,**只有点击**才展开/收起。
  629. // 只有一份状态(`summaryOpen`):hover / focus 都不碰它,所以鼠标掠过不会弹开、
  630. // 移开也不会把点击展开的内容收掉。展开态是纯展示状态,不进 store(面板私有、即挂即弃)。
  631. const summaryOpen = ref(false)
  632. const summaryDetailsId = computed(() => `panel-summary-details-${props.view.name || 'default'}`)
  633. /**
  634. * 折叠进去的摘要条目。三条纪律:
  635. * · **措辞与有无都由 domain 决定**,渲染层只按字段是否存在决定画不画,不重写、不合成;
  636. * · 每条**保留原有的 `data-*` 断言面**(走查脚本 t17/t18/t20 读的还是同一个选择器与同一句话)。
  637. * `v-show` 折叠时元素仍在 DOM 里,文本读得到 —— 折叠只改呈现,不改信息;
  638. * · 本表是顶部口径说明的**唯一**消费点([t56 闸门](../../.scratch/cockpit-real-data-wayfinding/verify/t56-panel-summary-disclosure.mjs)
  639. * E 组按 domain 声明双向锁定:domain 新增 `*Note`/`gap`/`note` 而不进这张表 → 判红;表内字段在 domain 已无声明 → 判红)。
  640. *
  641. * 后三条(`excludedNote` / `linkNote` / `note`)是第三轮补的:它们和前七条是同一件事,
  642. * 但渲染层**从来没有消费点**——「故障口径」「两族口径」「联动出口」「口径剔除」这些必须
  643. * 让值班人员看见的说明实际是隐形的。补法与票 49/50 的裁定一致:与其逐条发现,
  644. * 不如把完备性做成机制。
  645. */
  646. const SUMMARY_SOURCES = [
  647. { key: 'industry', field: 'industryNote', hook: 'data-panel-industry' },
  648. { key: 'type', field: 'typeNote', hook: 'data-panel-type-note' },
  649. { key: 'event', field: 'eventNote', hook: 'data-panel-event-note' },
  650. { key: 'range', field: 'rangeNote', hook: 'data-panel-range-note' },
  651. { key: 'gap', field: 'gap', hook: 'data-panel-gap', isGap: true },
  652. { key: 'offline', field: 'offlineNote', hook: 'data-panel-offline-note' },
  653. { key: 'page', field: 'pageNote', hook: 'data-panel-page-note' },
  654. { key: 'locate', field: 'locateNote', hook: 'data-locate-note' },
  655. { key: 'excluded', field: 'excludedNote', hook: 'data-panel-excluded-note' },
  656. { key: 'link', field: 'linkNote', hook: 'data-panel-link-note' },
  657. { key: 'note', field: 'note', hook: 'data-panel-note' },
  658. // 票 64:运行信息的数据来源与口径说明(设施实体适配器逐条生成、一条指标对应一个设施、
  659. // 不跨行业合并)。表是顶部说明的唯一消费点——domain 新增说明字段而不进这张表即判红
  660. // (t56 C 组的双向普查)。
  661. { key: 'source', field: 'sourceNote', hook: 'data-panel-source-note' },
  662. ]
  663. const summaryItems = computed(() =>
  664. SUMMARY_SOURCES.flatMap(({ key, field, hook, isGap }) => {
  665. const text = props.view[field]
  666. if (!text) return []
  667. return [{ key, text, isGap: Boolean(isGap), attrs: { [hook]: '', 'data-summary': key } }]
  668. }),
  669. )
  670. /**
  671. * 折叠态也要说得出「有没有要关注的东西」——只留标题会把口径缺口读成「一切正常」。
  672. * 判据是 domain 的措辞本身(`'当前窗口没有离线设备'` 是 `monitoringPanels.js` 给的无离线句),
  673. * 渲染层不自己算任何数。`note` / `gap` / `excludedNote` 同属口径说明一档。
  674. */
  675. const summaryStatus = computed(() => {
  676. const view = props.view
  677. if (view.offlineNote && view.offlineNote !== '当前窗口没有离线设备') return '有离线设备'
  678. if (view.gap || view.note || view.excludedNote) return '有口径提示'
  679. return `${summaryItems.value.length} 条说明`
  680. })
  681. /**
  682. * 票 38:维度切换的**组件本地状态**(键=面板名 + 块标题,值=当前维度键)。
  683. * 为什么放组件而不是 store:「换哪个维度看」不是全局条件(与 industry/district/deviceType
  684. * 不同层,切模式/刷新都不该动它);两个维度的行 domain 已经算完,这里只决定显示哪一份。
  685. * 键不用块对象:视图计划每次重算都生成新块对象,按对象记会被刷新顶掉(表现为「切了又跳回」)。
  686. */
  687. const dimensionOverrides = ref({})
  688. function blockKey(block) {
  689. return `${props.view.name}|${block.label}`
  690. }
  691. // ---- 票 64:list 块的统一分页(运行指标列表实测 6772 条,全量直渲=一屏几千张卡)----
  692. //
  693. // 四条纪律:
  694. // · **只有 domain 声明 `paginate` 的块切窗口**:同一个 list 画法服务全仓多处清单,
  695. // 管理信息明细(实测 9 行)与预警分析重点识别是规范排除项,应急保障另有专票(63),
  696. // 渲染层不猜、不顺手全翻;未声明的块 `windowOf` 原样交出 `block.rows`(同一引用、零 slice)。
  697. // · **翻页算术只有一份**(条文 11):窗口、越界夹回、页码寻址与复位全在
  698. // `domain/pagination.js`(`listPageKey`/`readListPage`/`writeListPage`/`resetListPages`
  699. // 共用同一个键函数——读写键不一致就会造出「该复位的没复位」这类最难查的漂移),
  700. // 控件只有 `PagerNav.vue` 一份;本组件不出现 ceil/trunc/rows.slice/自画 pager。
  701. // · **复位=显式清槽**(条文 5/6):domain 给每块一个 `pageScope`(选中行业 + 片区),
  702. // 它变了就清掉那一块的页码槽;定时刷新时它不变 ⇒ 页码保持。
  703. // 第一版把条件编进页码键,被条文 6 判住:切走再切回会读到旧槽位停在第 3 页,
  704. // 而「切回来」同样是一次条件变化。清槽与票 46/59「换设备清页码」同一条成例。
  705. // · **数据变少越界由 `pagedRows` 夹回**(条文 6),界面上的页码即夹回后的值。
  706. const listPages = ref({})
  707. /**
  708. * 分页块的条件快照(`{ [块归属键]: pageScope }`)。只收分页块——非分页块没有页码,
  709. * 也就没有可复位的槽;把它们放进来会让这张表每次刷新都变,白清一次别的块。
  710. */
  711. const listScopeSnapshot = computed(() => {
  712. const snapshot = {}
  713. for (const block of props.view.blocks || []) {
  714. if (block.paginate === true) snapshot[blockKey(block)] = block.pageScope || ''
  715. }
  716. return snapshot
  717. })
  718. // 哪一块的条件变了,就只清那一块的页码(别的面板/别的块不受牵连)。
  719. // 只按**值**逐块比较:computed 每次重算都是新对象,按对象身份比会把每次刷新都当成换筛选。
  720. watch(listScopeSnapshot, (next, prev) => {
  721. const before = prev || {}
  722. const changed = Object.keys(next).filter((key) => before[key] !== next[key])
  723. if (changed.length) listPages.value = resetListPages(listPages.value, changed)
  724. })
  725. /** 这一块当前生效的页码(没记过=第 1 页)。 */
  726. function pageOf(block) {
  727. return readListPage(listPages.value, blockKey(block))
  728. }
  729. /**
  730. * 这一块要画几条:分页块交窗口行,服务端分页块交**后端当前页**,其余块交全部行
  731. * (引用不变,既有渲染零影响)。两种分页形态共用同一个 PagerNav,只是页码来源不同:
  732. * · `paginate`(前端集合):渲染层自己的 `listPages` 切窗口;
  733. * · `serverPage`(服务端页码,票 61):行已是后端当前页,页码/总数取后端回显,
  734. * 翻页 emit `list-page` 交给 store 发请求。
  735. */
  736. function windowOf(block) {
  737. const rows = block.rows || []
  738. if (block.serverPage) {
  739. const page = Number(block.serverPage.page) || 1
  740. const pageCount = Number(block.serverPage.pageCount) || 1
  741. const total = Number.isFinite(Number(block.serverPage.total)) ? Number(block.serverPage.total) : rows.length
  742. return { rows, page, pageCount: Math.max(1, pageCount), total }
  743. }
  744. if (block.paginate !== true) return { rows, page: 1, pageCount: 1, total: rows.length }
  745. return pagedRows(rows, pageOf(block))
  746. }
  747. /**
  748. * 翻页唯一出口(与票 59 同一条):前后页/页码直达/跳页都带**目标页码(1 起)**回来。
  749. * 前端集合分页只存状态(越界由 domain `pagedRows` 在下一帧夹回);服务端分页把目标页
  750. * 交给父组件/store 发请求(页码状态在 store,块里不存第二份)。
  751. */
  752. function gotoListPage(block, target) {
  753. if (block.serverPage) {
  754. emit('list-page', { label: block.label, page: Number(target) || 1 })
  755. return
  756. }
  757. listPages.value = writeListPage(listPages.value, blockKey(block), target)
  758. }
  759. function activeDimension(block) {
  760. const override = dimensionOverrides.value[blockKey(block)]
  761. if (override && (block.dimensions || []).some((dim) => dim.key === override)) return override
  762. return block.active || (block.dimensions && block.dimensions[0] ? block.dimensions[0].key : '')
  763. }
  764. function currentDim(block) {
  765. const key = activeDimension(block)
  766. return (block.dimensions || []).find((dim) => dim.key === key) || null
  767. }
  768. function setDimension(block, key) {
  769. dimensionOverrides.value = { ...dimensionOverrides.value, [blockKey(block)]: key }
  770. }
  771. function isPlaceholder(text) {
  772. return text === DETAIL_PLACEHOLDER
  773. }
  774. /**
  775. * 一条 KPI 读数的**完整原文**(值 + 单位),供共用省略方法当 `title` 用(票 37)。
  776. * 显示仍走模板里的子元素(单位是独立的 `<em>`,有自己的弱化样式),
  777. * 但 title 取的是**整条读数**——所以窄列里被省略时,hover 读到的是"12 台"而不是"12"。
  778. *
  779. * 票 54:domain 可给 `full` 声明「屏上短读、原文另有其值」(数据时间的短读法 + 完整 ISO)。
  780. * 渲染层只认这个通用字段,不再按 `item.key` 特判哪个读数是时间(票 38 立的同一条纪律)。
  781. */
  782. function kpiReading(item) {
  783. const unit = item.unit && !isPlaceholder(item.value) ? ` ${item.unit}` : ''
  784. return `${item.full || item.value}${unit}`
  785. }
  786. /**
  787. * 票 14:饼图扇区的填充色。色键 → token CSS 变量(色值只在 tokens.css 声明一份,
  788. * 票 06 的单一口径;`var()` 在 SVG 属性里同样生效)。
  789. */
  790. function pieToneColor(tone, key) {
  791. const stateColors = {
  792. OFFLINE: 'var(--point-offline)',
  793. EMPTY: 'var(--point-empty)',
  794. UNKNOWN: 'var(--point-unknown)',
  795. UNASSOCIATED: 'var(--point-unassociated)',
  796. }
  797. if (stateColors[key]) return stateColors[key]
  798. const map = {
  799. lv1: 'var(--lv1, #ff3b52)',
  800. lv2: 'var(--lv2, #ffb020)',
  801. lv3: 'var(--lv3, #01abea)',
  802. lv4: 'var(--lv4, #27d17a)',
  803. }
  804. return map[tone] || 'var(--neutral, #5680b0)'
  805. }
  806. // ---- 图表 hover 交互(2026-10-14 维护者走查要求:悬停放大 + 数据详情浮层)----
  807. //
  808. // 三种图(堆叠条分段 / 折线数据点 / 饼·环形扇区)共用**一个**浮层状态与同一个
  809. // `.chart-tip` 浮层——「悬停放大」由各图自己的 CSS `:hover` / `.is-hovered` 完成,
  810. // 这里只负责「详情显示什么、浮在哪」。为什么不用原生 `title`:原生提示有 ~1s 延迟、
  811. // 样式不可控且与「放大」反馈不同步;面板内自绘浮层与 hover 是同一帧的。
  812. // 纯展示层状态:不进 store(面板私有、即挂即弃,不构成全局条件)。
  813. /** 当前悬停的图表详情(`{ label, value, color, x, y }`;null = 无悬停)。 */
  814. const chartTip = ref(null)
  815. /**
  816. * 票 43(第三轮根因):联动状态必须用 **shallowRef**,不能用 `ref`。
  817. * `ref(null)` 存入 `{owner: block}` 后会被 Vue **深层响应式代理**——读回的
  818. * `chartHover.value.owner` 是 block 的 reactive 代理,而模板 v-for 拿到的 `block`
  819. * 是原始对象,`===` 恒 false → `.is-hovered` 永不生效(扇区不放大、图例不亮),
  820. * 而浮层不依赖这个比较所以照常出现——这正是维护者看到的「只有详情、没有联动」。
  821. * `shallowRef` 不做深层包装(整体替换即触发渲染)。
  822. * (第四轮进一步发现:光保持身份还不够,见下方 `chartScope`——键根本不该是对象。)
  823. */
  824. const chartHover = shallowRef(null)
  825. /**
  826. * 票 43(第四轮根因):**不能拿对象身份当键**。外壳时钟每秒 `clockText` 变化会重渲染,
  827. * 而 `:view="panelViewOf(panelId)"` 是模板里的**方法调用**——每次重渲染都重算出一批全新的
  828. * block 对象。于是鼠标没动、hover 还在,下一次 tick 的 `block` 已经不是存进去的那个,
  829. * `owner === block` 静默变 false → 放大与联动高亮"自己消失"(浮层存的是标量所以不受影响,
  830. * 症状正是「详情还在、高亮掉了」)。
  831. * 修法=键换成**跨重算稳定的标量 scope**:视图名 + 块序号 + 块类型 + 块标题。
  832. * 这四样在同一面板同一块的数据刷新前后不变,既不会因重渲染漂移,
  833. * 也仍区分并排的同类图(视图名/序号不同)与同块内不同行(key 不同)。
  834. */
  835. function chartScope(index, block) {
  836. return `${props.view.name}|${index}|${block.kind}|${block.label || ''}`
  837. }
  838. function setChartHover(scope, key) {
  839. chartHover.value = { scope, key }
  840. }
  841. function clearChartHover(scope, key) {
  842. const current = chartHover.value
  843. if (current && current.scope === scope && current.key === key) {
  844. chartHover.value = null
  845. }
  846. }
  847. function isChartHovered(scope, key) {
  848. const current = chartHover.value
  849. return !!current && current.scope === scope && current.key === key
  850. }
  851. /** 图表元素悬停 → 记录详情与浮层锚点(相对 `.panel-blocks` 根,避免随滚动漂移)。 */
  852. function showChartTip(event, label, value, color) {
  853. const root = rootRef.value
  854. if (!root || !event.currentTarget) return
  855. const rect = root.getBoundingClientRect()
  856. const host = event.currentTarget.getBoundingClientRect()
  857. const x = host.left + host.width / 2 - rect.left
  858. const y = host.top - rect.top
  859. // 面板底部/顶部被滚动裁切时,把浮层移到元素下方,避免悬停详情跑出可视区域。
  860. const top = Math.max(8, y)
  861. chartTip.value = {
  862. label,
  863. value,
  864. color,
  865. x,
  866. y: top,
  867. }
  868. }
  869. /** 离开图表元素:收起浮层(除非已移入浮层本身——浮层 pointer-events:none,不需要)。 */
  870. function hideChartTip() {
  871. chartTip.value = null
  872. }
  873. /**
  874. * 分类色键 → CSS 颜色(2026-10-13 维护者要求:图表用行业语义色区分类别)。
  875. * `industry:*` → 行业 token(与行业切图/地图标记同源);`lv*`/`neutral` → 语义 token;
  876. * `chart:N` → 图表色序 token。空键回主色(无语义的行仍可辨)。
  877. */
  878. const BAR_TONE_COLORS = {
  879. 'industry:gas': 'var(--industry-gas, #fe3b53)',
  880. 'industry:water': 'var(--industry-water, #01abea)',
  881. 'industry:drainage': 'var(--industry-drainage, #27d17a)',
  882. 'industry:manhole': 'var(--industry-manhole, #a970ff)',
  883. lv1: 'var(--lv1, #ff3b52)',
  884. lv2: 'var(--lv2, #ffb020)',
  885. lv3: 'var(--lv3, #01abea)',
  886. lv4: 'var(--lv4, #27d17a)',
  887. neutral: 'var(--neutral, #5680b0)',
  888. 'point-offline': 'var(--point-offline)',
  889. 'point-empty': 'var(--point-empty)',
  890. 'point-unknown': 'var(--point-unknown)',
  891. 'point-unassociated': 'var(--point-unassociated)',
  892. 'chart:1': 'var(--chart-c1, #01abea)',
  893. 'chart:2': 'var(--chart-c2, #00b8ff)',
  894. 'chart:3': 'var(--chart-c3, #33b3ed)',
  895. 'chart:4': 'var(--chart-c4, #14c2ff)',
  896. 'chart:5': 'var(--chart-c5, #27d17a)',
  897. 'chart:6': 'var(--chart-c6, #ffb020)',
  898. 'chart:7': 'var(--chart-c7, #a970ff)',
  899. 'chart:8': 'var(--chart-c8, #5680b0)',
  900. }
  901. function barColor(tone) {
  902. return BAR_TONE_COLORS[tone] || 'var(--cyan, #01abea)'
  903. }
  904. /**
  905. * 堆叠条分段色:语义 tone 优先;该语义在块内**重复出现**时(如工单四个待办态都是 lv3)
  906. * 用 shade 深浅区分——语义色与图表色按 shade 插值,行间可辨。纯函数(toneShared 由 domain 判定)。
  907. */
  908. function stackedColor(row) {
  909. const base = barColor(row.tone)
  910. if (!row.toneShared) return base
  911. return `color-mix(in srgb, ${base} ${Math.round(100 - (row.shade ?? 0.5) * 55)}%, var(--chart-c2, #00b8ff))`
  912. }
  913. // ---- 台账卡片的设备徽标:**卡片进视口才要**(票 18 的 Q14 裁定;票 62 收窄到当前窗口)----
  914. //
  915. // 为什么必须是"进视口才拉"而不是"一页出来就全拉":台账的分页窗口横跨「监测点 + 未关联设备」,
  916. // 一页可能几十张卡,而用户常常只看最上面几张;更关键的是**逐点位一次请求**(本端点不给设备编码,
  917. // 见 `domain/monitoringPanels.js` 的说明),无脑全拉会在一屏之内打出一串并发请求。
  918. //
  919. // 票 62:台账接上统一页码分页后,**DOM 里只有当前页窗口的卡片**(≤10 张),
  920. // 于是"进视口才拉"自然只覆盖当前窗口 —— 翻到第 2 页才会为第 11–20 张要徽标。
  921. // 这比旧形态(一页 500 张卡都进观察集)负载低一个量级,是票面 Acceptance 第 3 条要的形态。
  922. //
  923. // 组件不自己取数(那一层在 store)——它只回答「这张卡露出来了」,
  924. // 由外壳转给 `monitoringStore.loadLedgerBadge()`。请求过的点位由 store 记账,不会重复要。
  925. //
  926. // 注意 ref 挂在**根元素**上而不是那个 `v-for` 里的 `div`:`v-for` 上的 ref 在 Vue 3 里
  927. // 收回来的是**数组**(每个面板实例一个元素),`listRef.value.querySelectorAll` 会直接抛
  928. // "not a function" —— 而且抛在 `onMounted` 里会把整页的后续挂载打断
  929. // (真机走查第一轮就是这样:一个组件报错,A8b/D1/D2/D3 一起红)。
  930. const rootRef = ref(null)
  931. let observer = null
  932. /** 已经交给外面要过徽标的点位(组件这侧也记一份,避免 observer 反复触发同一张卡)。 */
  933. const requested = new Set()
  934. function observeCards() {
  935. if (typeof IntersectionObserver === 'undefined' || !rootRef.value) return
  936. for (const card of rootRef.value.querySelectorAll('[data-ledger-card]')) {
  937. const code = card.dataset.ledgerCard || ''
  938. if (!code || requested.has(code)) continue
  939. requested.add(code)
  940. observer.observe(card)
  941. }
  942. }
  943. onMounted(() => {
  944. if (typeof IntersectionObserver === 'undefined') {
  945. // 没有 IntersectionObserver 时**退化为全要**:徽标是可选增强,不该因此整块不显示。
  946. // 票 62:只覆盖**当前页窗口**的行(DOM 窗口与视图窗口同一份 `windowOf`)。
  947. for (const block of props.view.blocks || []) {
  948. if (block.kind !== 'list') continue
  949. for (const row of windowOf(block).rows) {
  950. if (row.code && !requested.has(row.code)) {
  951. requested.add(row.code)
  952. emit('badge-needed', row.code)
  953. }
  954. }
  955. }
  956. return
  957. }
  958. observer = new IntersectionObserver(
  959. (entries) => {
  960. for (const entry of entries) {
  961. if (!entry.isIntersecting) continue
  962. const code = (entry.target.dataset && entry.target.dataset.ledgerCard) || ''
  963. // 要过就不再要(store 也会挡一道,这里是省一次事件转发)。
  964. if (code) emit('badge-needed', code)
  965. observer.unobserve(entry.target)
  966. }
  967. },
  968. // 提前 80px 触发:等卡片真正露出来再拉,用户会先看到"取数中"再看到数字。
  969. { rootMargin: '80px 0px' },
  970. )
  971. observeCards()
  972. })
  973. // 分页追加、或换筛选后重渲染:新卡片要补进观察集。
  974. watch(
  975. () => props.view.blocks,
  976. () => observeCards(),
  977. { deep: false },
  978. )
  979. onBeforeUnmount(() => {
  980. if (observer) {
  981. observer.disconnect()
  982. observer = null
  983. }
  984. requested.clear()
  985. })
  986. </script>
  987. <style scoped>
  988. .panel-summary-disclosure {
  989. position: relative;
  990. /* 头部是 flex-wrap 容器:折叠入口独占一行(`flex-basis: 100%`),长句才能在栏宽内换行,
  991. 不被 max-content 顶出横向溢出(票 13 的「右侧被裁」是同一类缺陷)。 */
  992. flex: 1 1 100%;
  993. min-width: 0;
  994. }
  995. .panel-summary-disclosure__toggle {
  996. display: inline-flex;
  997. align-items: center;
  998. gap: calc(5 * var(--u, 1px));
  999. min-height: calc(24 * var(--u, 1px));
  1000. padding: calc(2 * var(--u, 1px)) calc(7 * var(--u, 1px));
  1001. border: 1px solid rgba(1, 171, 234, 0.38);
  1002. border-radius: 3px;
  1003. background: rgba(0, 20, 72, 0.58);
  1004. color: var(--text-2, #88b7e8);
  1005. font: inherit;
  1006. font-size: var(--fs-caption, 10.5px);
  1007. cursor: pointer;
  1008. }
  1009. .panel-summary-disclosure__toggle:hover,
  1010. .panel-summary-disclosure__toggle:focus-visible {
  1011. border-color: var(--cyan-hi, #00b8ff);
  1012. color: var(--text-strong, #fff);
  1013. outline: none;
  1014. }
  1015. .panel-summary-disclosure__toggle b { color: var(--text, #d7ecff); font-weight: 700; }
  1016. .panel-summary-disclosure__toggle i { color: var(--muted, #5680b0); font-style: normal; }
  1017. .panel-summary-disclosure__details {
  1018. display: grid;
  1019. gap: calc(3 * var(--u, 1px));
  1020. margin-top: calc(4 * var(--u, 1px));
  1021. padding: calc(4 * var(--u, 1px)) calc(7 * var(--u, 1px));
  1022. border-left: 2px solid var(--cyan, #01abea);
  1023. background: rgba(0, 20, 72, 0.42);
  1024. }
  1025. /* 展开区内每条摘要自己收口:等宽长串(统计窗口的 ISO 时间)按字符断行,不溢出面板。 */
  1026. .panel-summary-disclosure__details > span {
  1027. min-width: 0;
  1028. overflow-wrap: anywhere;
  1029. }
  1030. .panel-summary-disclosure__details > .panel-blocks__gap {
  1031. margin: 0;
  1032. }
  1033. .panel-blocks,
  1034. .panel-blocks *,
  1035. .panel-blocks *::before,
  1036. .panel-blocks *::after {
  1037. box-sizing: border-box;
  1038. }
  1039. .panel-blocks {
  1040. /* 图表 hover 详情浮层(.chart-tip)的定位基准:坐标按本根元素算。 */
  1041. position: relative;
  1042. display: grid;
  1043. gap: calc(6 * var(--u, 1px));
  1044. align-content: start;
  1045. min-height: 0;
  1046. }
  1047. .panel-blocks__head {
  1048. display: flex;
  1049. flex-wrap: wrap;
  1050. align-items: center;
  1051. gap: var(--space-2, 8px);
  1052. }
  1053. .panel-blocks__note {
  1054. margin: 0;
  1055. color: var(--muted, #5680b0);
  1056. font-size: var(--fs-caption, 10.5px);
  1057. letter-spacing: 0.04em;
  1058. }
  1059. /* 契约缺口:用「关注」色而不是错误色 —— 它不是故障,是后端还没有这个字段。 */
  1060. .panel-blocks__gap {
  1061. margin: 0;
  1062. padding: calc(3 * var(--u, 1px)) var(--space-2, 8px);
  1063. border-left: 2px solid var(--lv2, #ffb020);
  1064. background: rgba(255, 176, 32, 0.08);
  1065. color: var(--text-2, #88b7e8);
  1066. font-size: var(--fs-caption, 10.5px);
  1067. line-height: 1.5;
  1068. }
  1069. .panel-block {
  1070. display: grid;
  1071. gap: calc(3 * var(--u, 1px));
  1072. }
  1073. .panel-block__title {
  1074. margin: 0;
  1075. color: var(--text-2, #88b7e8);
  1076. font-size: var(--fs-caption, 10.5px);
  1077. font-weight: 700;
  1078. letter-spacing: 0.06em;
  1079. }
  1080. .panel-kpis {
  1081. display: grid;
  1082. grid-template-columns: repeat(auto-fit, minmax(calc(78 * var(--u, 1px)), 1fr));
  1083. gap: var(--space-2, 8px);
  1084. }
  1085. /* 票 54:监测概览四卡在常规宽度固定 2×2,避免 auto-fit 展开成一行。
  1086. **降级按容器判、不按视口判**:面板栏宽是 `--column-w = clamp(280px, 330u, 460px)`
  1087. (styles/tokens.css:135),`--u = clamp(1px, 0.0694vw, 2px)`。栏内容宽实测:1920 视口
  1088. ≈410px、1440 视口(u 收到 1)≈308px —— 两档都稳在 2×2。写视口 media query 等于写一条
  1089. 在大屏驾驶舱里永不触发的规则,所以这里查的是**容器**宽度。
  1090. **260px 这个阈值的来源**(不是拍的):单格宽 =(容器 − gap 8u)/2;读数行 = 单格 − 卡片
  1091. 左右内边距 16u;`--fs-kpi = 20u` 下「10-12 15:15」11 个等宽字符 ≈ 110u。
  1092. 读数不省略要求 单格 ≥ 126u → 容器 ≥ 2×126 + 8 = 260u。低于它退单列。
  1093. 两档都不横向溢出:列宽 `minmax(0, 1fr)`(不是 auto),卡片内文本走共用省略方法。
  1094. 形状说明:**容器查不了自己的尺寸**(CSS containment 的规则,`container-type` 加在谁身上,
  1095. `@container` 就只对谁的后代生效),所以降级写在子卡上 `grid-column: 1 / -1`
  1096. 横贯两列,而不是改父元素的 `grid-template-columns`。
  1097. `container-type` 只加在本变体上:本元素内没有绝对定位后代(图表 hover 浮层的定位基准
  1098. 仍是 `.panel-blocks` 根),因此不影响其它块的布局。 */
  1099. .panel-kpis--two-by-two {
  1100. grid-template-columns: repeat(2, minmax(0, 1fr));
  1101. container-type: inline-size;
  1102. container-name: panel-kpi-grid;
  1103. }
  1104. @container panel-kpi-grid (max-width: 260px) {
  1105. .panel-kpis--two-by-two .panel-kpi {
  1106. grid-column: 1 / -1;
  1107. }
  1108. }
  1109. .panel-kpi {
  1110. display: grid;
  1111. grid-template-columns: auto minmax(0, 1fr);
  1112. align-items: center;
  1113. column-gap: calc(5 * var(--u, 1px));
  1114. row-gap: 1px;
  1115. padding: calc(4 * var(--u, 1px)) var(--space-2, 8px);
  1116. border: 1px solid rgba(1, 171, 234, 0.24);
  1117. border: 1px solid rgba(1, 171, 234, 0.35);
  1118. /* 2026-10-23 维护者要求:面板背景取消颜色渐变后,颜色渐变转移到数据统计指标卡片 ——
  1119. .panel-kpi 用 tokens 的 --stat-card-bg(亮科技蓝 → 深蓝,色相可感知)。 */
  1120. border-radius: calc(8 * var(--u, 1px));
  1121. background: var(--stat-card-bg, linear-gradient(180deg, rgba(0, 82, 155, 0.45), rgba(0, 28, 70, 0.6)));
  1122. }
  1123. /* KPI 图标:与标签同一行,弱发光(同面板标题图标的弱辉光档)。
  1124. 颜色由容器 currentColor 给(KPI_ICON_COLORS 语义色),mask 图标本体跟随。 */
  1125. .panel-kpi__icon {
  1126. display: inline-flex;
  1127. align-items: center;
  1128. color: var(--cyan, #01abea);
  1129. filter: drop-shadow(0 0 4px rgba(1, 171, 234, 0.45));
  1130. }
  1131. /* 票 37:越界省略与 hover 完整原文都归 `EllipsisText.vue` 的 `.ellipsis-text` 一份声明;
  1132. 本文件里的类只管字体、颜色与排布。 */
  1133. .panel-kpi__label {
  1134. color: var(--muted, #5680b0);
  1135. font-size: var(--fs-caption, 10.5px);
  1136. letter-spacing: 0.04em;
  1137. }
  1138. /* 数值行跨两列(图标 + 标签在上一行,数值独占下一行全宽);
  1139. 口径说明(hint)**同样独占一行**:它若落进第一列(auto 列),
  1140. 会被说明文字把该列撑宽、把 minmax(0,1fr) 的标签列挤到 0 —— 标签被省略成"消失"
  1141. (真机走查 2026-10-14:在线率 / 预警已解除率两张卡的标题整行不见,根因即此)。 */
  1142. .panel-kpi__value,
  1143. .panel-kpi__value--link,
  1144. .panel-kpi__hint {
  1145. grid-column: 1 / -1;
  1146. }
  1147. /* 票 38 第二轮(反馈第 1 条):整行独占**回归**,但改由 domain 的通用 `wide` 标志声明
  1148. (概览基础设施的管线里程数值最长,独占一行排版更好看)。
  1149. 与第一轮删掉的那版区别:那时是渲染层按 `item.key === 'lengthKm'` 特判(第二份布局语义),
  1150. 现在组件只认 `item.wide` 这个通用呈现意图,「谁独占」的决定权在 domain。
  1151. 列布局沿用 HEAD 原样(图标 / 标签 / 右对齐数值三列)——「占一整行」由 `grid-column`
  1152. 表达,不需要顺带改写卡片内部排布。 */
  1153. .panel-kpi--wide {
  1154. grid-column: 1 / -1;
  1155. grid-template-columns: auto minmax(0, 1fr) auto;
  1156. padding: var(--space-2);
  1157. }
  1158. .panel-kpi--wide .panel-kpi__value {
  1159. grid-column: 3;
  1160. text-align: right;
  1161. font-size: var(--fs-kpi);
  1162. }
  1163. .panel-kpi__value {
  1164. min-width: 0;
  1165. color: var(--text-strong, #fff);
  1166. font-family: var(--num, monospace);
  1167. /* 批次 2(票 15):KPI 数值是设计稿最显眼的档位(20px Rajdhani),单列 --fs-kpi。 */
  1168. font-size: var(--fs-kpi, 20px);
  1169. line-height: 1.2;
  1170. }
  1171. /* 票 54:原先这里有一条**时间读数专用降档**(把数据时间压到 caption 字号),
  1172. 它正是「数据时间与上一行四卡不在同一视觉层级」的根因。所有 KPI 数值共用
  1173. `.panel-kpi__value` 一档之后它失去全部消费点,按规格 03「无消费点不提交」整条删除;
  1174. 短读法归 domain 声明(`createdAtKpi`),渲染层不再按 `item.key` 认哪个读数是时间。 */
  1175. .panel-kpi__value em {
  1176. color: var(--muted, #5680b0);
  1177. font-style: normal;
  1178. font-size: var(--fs-caption, 10.5px);
  1179. }
  1180. /* 票 20:可点的 KPI 读数(联动出口)。抹掉按钮默认外观,但保留可聚焦与 hover 反馈 ——
  1181. 与 `.panel-bar--button` 同一立场:可点的东西要在键盘上也能到达。 */
  1182. .panel-kpi__value--link {
  1183. padding: 0;
  1184. border: 0;
  1185. background: transparent;
  1186. font: inherit;
  1187. font-family: var(--num, monospace);
  1188. font-size: var(--fs-kpi, 20px);
  1189. text-align: left;
  1190. cursor: pointer;
  1191. border-bottom: 1px dashed rgba(1, 171, 234, 0.45);
  1192. }
  1193. .panel-kpi__value--link:hover,
  1194. .panel-kpi__value--link:focus-visible {
  1195. color: var(--text-strong);
  1196. border-bottom-color: var(--cyan, #01abea);
  1197. }
  1198. .panel-kpi__value--link:focus-visible {
  1199. outline: 1px solid var(--cyan, #01abea);
  1200. outline-offset: 1px;
  1201. }
  1202. /* 可点分布的 hover 反馈与状态分布行同款(`block.action` 版)。 */
  1203. .panel-bars--clickable .panel-bar--button:hover .panel-bar__count,
  1204. .panel-bars--clickable .panel-bar--button:focus-visible .panel-bar__count {
  1205. color: var(--text);
  1206. }
  1207. .panel-kpi__hint {
  1208. color: var(--muted, #5680b0);
  1209. font-size: var(--fs-caption, 10.5px);
  1210. }
  1211. .panel-bars {
  1212. display: grid;
  1213. gap: calc(2 * var(--u, 1px));
  1214. }
  1215. /* 票 38:dimbars 的维度切换标签 + 柱列。柱/条复用 .panel-bar 的同一套画法,
  1216. 这里只管切换器本身——小胶囊按钮,选中态与顶栏筛选 chips 同一视觉语言(is-active)。 */
  1217. .panel-dimbars {
  1218. display: grid;
  1219. gap: var(--space-2, 8px);
  1220. }
  1221. .panel-dimbars__tabs {
  1222. display: flex;
  1223. gap: calc(4 * var(--u, 1px));
  1224. }
  1225. .panel-dimbars__tab {
  1226. padding: calc(2 * var(--u, 1px)) calc(8 * var(--u, 1px));
  1227. border: 1px solid rgba(1, 171, 234, 0.24);
  1228. border-radius: calc(8 * var(--u, 1px));
  1229. background: var(--inset, rgba(2, 10, 20, 0.55));
  1230. color: var(--text-2, #88b7e8);
  1231. font-family: var(--font-topbar);
  1232. font-size: var(--fs-caption, 10.5px);
  1233. cursor: pointer;
  1234. }
  1235. .panel-dimbars__tab.is-active {
  1236. color: var(--text-strong, #fff);
  1237. border-color: rgba(0, 184, 255, 0.85);
  1238. background: rgba(1, 171, 234, 0.16);
  1239. }
  1240. .panel-dimbars__tab:focus-visible {
  1241. outline: 1px solid var(--cyan, #01abea);
  1242. outline-offset: 2px;
  1243. }
  1244. /* 可点的状态分布行:按钮要抹掉浏览器默认外观,但仍保留可聚焦与 hover 反馈。 */
  1245. .panel-bars--clickable .panel-bar--button {
  1246. width: 100%;
  1247. padding: 1px 0;
  1248. border: 0;
  1249. background: transparent;
  1250. font: inherit;
  1251. text-align: left;
  1252. cursor: pointer;
  1253. }
  1254. .panel-bar--button:hover .panel-bar__label,
  1255. .panel-bar--button:focus-visible .panel-bar__label {
  1256. color: var(--text);
  1257. }
  1258. .panel-bar--button:focus-visible {
  1259. outline: 1px solid var(--cyan, #01abea);
  1260. outline-offset: 1px;
  1261. }
  1262. /* 选中态:整行加一条左边线 + 底色,不改变条长口径(选中不该被读成"计数变了")。
  1263. 票 37 起由 `row.selected` 驱动(此前样式在、值没人传,选中行与未选中行画面上分不出)。 */
  1264. .panel-bar--button.is-active {
  1265. background: rgba(1, 171, 234, 0.12);
  1266. box-shadow: inset 2px 0 0 var(--cyan, #01abea);
  1267. }
  1268. /* 票 44(视觉密度):文字与统计条收紧(8u→4u),读数列同档;行距不变,密度靠"贴紧"而不是"堆挤"。 */
  1269. .panel-bar {
  1270. display: grid;
  1271. grid-template-columns: calc(64 * var(--u, 1px)) minmax(0, 1fr) calc(26 * var(--u, 1px));
  1272. align-items: center;
  1273. gap: calc(4 * var(--u, 1px));
  1274. }
  1275. .panel-bar__label {
  1276. color: var(--text-2, #88b7e8);
  1277. font-size: var(--fs-caption, 10.5px);
  1278. }
  1279. /* 轨道:5u→6u 并加内衬与 1u 发丝边 —— 条段有明确的容器边界,不再与相邻行的辉光糊在一起。 */
  1280. .panel-bar__track {
  1281. position: relative;
  1282. display: block;
  1283. height: calc(6 * var(--u, 1px));
  1284. background: linear-gradient(180deg, rgba(1, 171, 234, 0.1), rgba(1, 171, 234, 0.22));
  1285. box-shadow: inset 0 0 0 1px rgba(1, 171, 234, 0.12);
  1286. }
  1287. /* 票 44(条段分割):删掉旧的外辉光 `0 0 8px` —— 辉光越过 2u 行距渗进相邻条,
  1288. 正是"条段之间没有明确分割"的直接原因。层次改由填充自身的顶部高光线 + 右端头帽给
  1289. (::after 装饰层,pointer-events:none 不挡悬停;行 hover 的增亮反馈仍归 .panel-bar__fill 本体)。 */
  1290. .panel-bar__fill {
  1291. position: relative;
  1292. display: block;
  1293. height: 100%;
  1294. background: linear-gradient(90deg, var(--cyan-d, #009de9), var(--cyan, #01abea));
  1295. }
  1296. .panel-bar__fill::after {
  1297. content: '';
  1298. position: absolute;
  1299. inset: 0;
  1300. background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 55%);
  1301. box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.85);
  1302. pointer-events: none;
  1303. }
  1304. .panel-bar__count {
  1305. color: var(--text);
  1306. font-family: var(--num, monospace);
  1307. font-size: var(--fs-caption, 10.5px);
  1308. text-align: right;
  1309. }
  1310. /* 趋势:横向分桶的柱列。高度按最大桶归一;0 也留 2% 高度,好让"这个桶存在且为 0"看得出来。 */
  1311. .panel-trend {
  1312. display: flex;
  1313. align-items: flex-end;
  1314. gap: calc(2 * var(--u, 1px));
  1315. height: calc(52 * var(--u, 1px));
  1316. padding-top: calc(2 * var(--u, 1px));
  1317. }
  1318. /* 折线形态(2026-10-13 维护者第三轮美化:带 Y 轴刻度、网格线、面积、点顶数值)。
  1319. 容器改列布局:绘图区 + X 轴标签行;绘图区左侧留刻度槽。 */
  1320. .panel-trend--line {
  1321. display: grid;
  1322. grid-template-rows: minmax(0, 1fr) auto;
  1323. gap: calc(2 * var(--u, 1px));
  1324. height: calc(78 * var(--u, 1px));
  1325. padding: calc(8 * var(--u, 1px)) 0 calc(1 * var(--u, 1px));
  1326. }
  1327. .panel-trend__chart {
  1328. position: relative;
  1329. height: 100%;
  1330. min-height: 0;
  1331. }
  1332. /* 绘图区:左侧让出刻度槽,SVG 与数据点/标签都以它为一个坐标基准。 */
  1333. .panel-trend__plot {
  1334. position: absolute;
  1335. inset: 0 0 0 calc(20 * var(--u, 1px));
  1336. }
  1337. /* 网格线:0/半程/峰值三档;刻度标签落在左侧槽里。 */
  1338. .panel-trend__grid {
  1339. position: absolute;
  1340. left: calc(20 * var(--u, 1px));
  1341. right: 0;
  1342. height: 0;
  1343. border-top: 1px dashed rgba(1, 171, 234, 0.14);
  1344. }
  1345. .panel-trend__grid.is-top {
  1346. border-top-color: rgba(1, 171, 234, 0.3);
  1347. }
  1348. .panel-trend__ytick {
  1349. position: absolute;
  1350. left: calc(-20 * var(--u, 1px));
  1351. top: -0.55em;
  1352. width: calc(17 * var(--u, 1px));
  1353. color: var(--muted, #5680b0);
  1354. font-family: var(--num, monospace);
  1355. font-size: var(--fs-caption, 10.5px);
  1356. font-weight: 400;
  1357. line-height: 1;
  1358. text-align: right;
  1359. }
  1360. /* 折线 + 面积:SVG 铺满绘图区,viewBox 0–100 与百分比坐标同基准。 */
  1361. .panel-trend__line {
  1362. position: absolute;
  1363. inset: 0;
  1364. width: 100%;
  1365. height: 100%;
  1366. overflow: visible;
  1367. }
  1368. .panel-trend__area {
  1369. fill: rgba(1, 171, 234, 0.12);
  1370. }
  1371. .panel-trend__line-path {
  1372. stroke: var(--cyan, #01abea);
  1373. stroke-width: 1.6;
  1374. stroke-linejoin: round;
  1375. stroke-linecap: round;
  1376. vector-effect: non-scaling-stroke;
  1377. filter: drop-shadow(0 0 3px rgba(1, 171, 234, 0.55));
  1378. }
  1379. /* 数据点(HTML 叠层,保持正圆):以点自身中心对齐坐标。 */
  1380. .panel-trend__node {
  1381. position: absolute;
  1382. width: calc(5 * var(--u, 1px));
  1383. height: calc(5 * var(--u, 1px));
  1384. transform: translate(-50%, 50%);
  1385. }
  1386. .panel-trend__dot {
  1387. display: block;
  1388. width: 100%;
  1389. height: 100%;
  1390. border-radius: 50%;
  1391. background: var(--cyan-hi, #00b8ff);
  1392. box-shadow: 0 0 4px rgba(1, 171, 234, 0.8);
  1393. }
  1394. .panel-trend__node.is-peak .panel-trend__dot {
  1395. background: var(--lv2, #ffb020);
  1396. box-shadow: 0 0 5px rgba(255, 176, 32, 0.9);
  1397. }
  1398. /* 点顶数值标签:悬在点上方居中,等宽数字。 */
  1399. .panel-trend__vlabel {
  1400. position: absolute;
  1401. bottom: calc(100% + 2px);
  1402. left: 50%;
  1403. transform: translateX(-50%);
  1404. color: var(--text, #d7ecff);
  1405. font-family: var(--num, monospace);
  1406. font-size: var(--fs-caption, 10.5px);
  1407. font-weight: 700;
  1408. line-height: 1;
  1409. white-space: nowrap;
  1410. }
  1411. .panel-trend__node.is-peak .panel-trend__vlabel {
  1412. color: var(--lv2, #ffb020);
  1413. }
  1414. /* X 轴标签行:绝对定位到各点 x(与绘图区同基准),按 axisEvery 抽样,首尾必显。 */
  1415. .panel-trend__axis {
  1416. position: relative;
  1417. height: calc(12 * var(--u, 1px));
  1418. margin-left: calc(20 * var(--u, 1px));
  1419. }
  1420. /* 票 37:`white-space: nowrap` 与省略标记一并归 `.ellipsis-text`(唯一一份)。 */
  1421. .panel-trend__axis .panel-trend__label {
  1422. position: absolute;
  1423. top: 0;
  1424. transform: translateX(-50%);
  1425. color: var(--muted, #5680b0);
  1426. font-size: var(--fs-caption, 10.5px);
  1427. line-height: 1.1;
  1428. }
  1429. .panel-trend__axis .panel-trend__label:first-child {
  1430. transform: none;
  1431. }
  1432. .panel-trend__axis .panel-trend__label:last-child {
  1433. transform: translateX(-100%);
  1434. }
  1435. /* 百分比堆叠条(2026-10-13 图表选型:多类别占比)。 */
  1436. .panel-stacked {
  1437. display: grid;
  1438. gap: calc(4 * var(--u, 1px));
  1439. }
  1440. .panel-stacked__bar {
  1441. position: relative;
  1442. display: flex;
  1443. height: calc(8 * var(--u, 1px));
  1444. background: rgba(1, 171, 234, 0.12);
  1445. overflow: visible;
  1446. }
  1447. .panel-stacked__seg {
  1448. position: absolute;
  1449. top: 0;
  1450. bottom: 0;
  1451. /* 2026-10-13:分段色由语义 tone 给(--seg-color,组件注入);shade 兜底删色。 */
  1452. background: var(--seg-color, var(--cyan, #01abea));
  1453. }
  1454. .panel-stacked__legend {
  1455. display: grid;
  1456. grid-template-columns: repeat(auto-fill, minmax(calc(96 * var(--u, 1px)), 1fr));
  1457. gap: calc(2 * var(--u, 1px)) var(--space-2, 8px);
  1458. margin: 0;
  1459. padding: 0;
  1460. list-style: none;
  1461. }
  1462. .panel-stacked__row {
  1463. display: flex;
  1464. align-items: center;
  1465. gap: calc(4 * var(--u, 1px));
  1466. min-width: 0;
  1467. }
  1468. .panel-stacked__swatch {
  1469. flex: none;
  1470. width: calc(8 * var(--u, 1px));
  1471. height: calc(8 * var(--u, 1px));
  1472. background: var(--seg-color, var(--cyan, #01abea));
  1473. }
  1474. /* 环形图(2026-10-13:条形图疲劳后的占比形态;左图右例、中心合计)。 */
  1475. .panel-donut {
  1476. display: flex;
  1477. align-items: center;
  1478. gap: var(--space-3, 12px);
  1479. min-width: 0;
  1480. }
  1481. .panel-donut__wrap {
  1482. position: relative;
  1483. flex: none;
  1484. width: calc(72 * var(--u, 1px));
  1485. height: calc(72 * var(--u, 1px));
  1486. }
  1487. .panel-donut__chart {
  1488. display: block;
  1489. width: 100%;
  1490. height: 100%;
  1491. }
  1492. .panel-donut__slice {
  1493. stroke: rgba(1, 5, 20, 0.6);
  1494. stroke-width: 1;
  1495. }
  1496. .panel-donut__total {
  1497. position: absolute;
  1498. inset: 0;
  1499. display: grid;
  1500. place-items: center;
  1501. /* 票 43(重开):中心读数让位给整楔命中区——它盖在圆心上方,
  1502. 不关掉指针事件的话「悬停饼图中心」永远触发不了最近的扇区。 */
  1503. pointer-events: none;
  1504. color: var(--text-strong, #fff);
  1505. font-family: var(--num, monospace);
  1506. font-size: var(--fs-body, 14px);
  1507. }
  1508. .panel-donut__legend {
  1509. display: grid;
  1510. gap: calc(2 * var(--u, 1px));
  1511. margin: 0;
  1512. padding: 0;
  1513. list-style: none;
  1514. min-width: 0;
  1515. flex: 1 1 auto;
  1516. }
  1517. .panel-donut__row {
  1518. display: flex;
  1519. align-items: center;
  1520. gap: calc(4 * var(--u, 1px));
  1521. min-width: 0;
  1522. }
  1523. /* 可点图例行(行业构成联动):抹按钮默认外观,保留 hover/focus(同 .panel-bar--button 立场)。 */
  1524. .panel-donut__row--button {
  1525. width: 100%;
  1526. padding: 0;
  1527. border: 0;
  1528. background: transparent;
  1529. font: inherit;
  1530. text-align: left;
  1531. cursor: pointer;
  1532. }
  1533. .panel-donut__row--button:hover .panel-donut__label,
  1534. .panel-donut__row--button:focus-visible .panel-donut__label {
  1535. color: var(--text-strong, #fff);
  1536. }
  1537. .panel-donut__row--button:focus-visible {
  1538. outline: 1px solid var(--cyan, #01abea);
  1539. outline-offset: 1px;
  1540. }
  1541. .panel-donut__swatch {
  1542. flex: none;
  1543. width: calc(8 * var(--u, 1px));
  1544. height: calc(8 * var(--u, 1px));
  1545. }
  1546. .panel-donut__label {
  1547. /* 票 43(重开):数值紧跟标签成对。`flex: 0 1 auto` 而不是 `1 1 auto`——
  1548. 会生长的标签把自己撑满整行、把数值又顶回行尾(正是维护者说的"离得太远");
  1549. 只收缩才能在"标签+数值"之间零间隙成对,同时窄面板里仍可省略。 */
  1550. flex: 0 1 auto;
  1551. min-width: 0;
  1552. color: var(--text-2, #88b7e8);
  1553. font-size: var(--fs-caption, 10.5px);
  1554. }
  1555. .panel-donut__count {
  1556. flex: none;
  1557. color: var(--text, #d7ecff);
  1558. font-family: var(--num, monospace);
  1559. font-size: var(--fs-caption, 10.5px);
  1560. }
  1561. .panel-stacked__label {
  1562. color: var(--text-2, #88b7e8);
  1563. font-size: var(--fs-caption, 10.5px);
  1564. }
  1565. .panel-stacked__count {
  1566. flex: none;
  1567. color: var(--text, #d7ecff);
  1568. font-family: var(--num, monospace);
  1569. font-size: var(--fs-caption, 10.5px);
  1570. }
  1571. .panel-trend__point {
  1572. display: flex;
  1573. flex: 1 1 0;
  1574. flex-direction: column;
  1575. align-items: center;
  1576. justify-content: flex-end;
  1577. gap: 1px;
  1578. min-width: 0;
  1579. height: 100%;
  1580. }
  1581. .panel-trend__bar {
  1582. display: block;
  1583. width: 100%;
  1584. background: linear-gradient(180deg, var(--cyan, #01abea), var(--cyan-d, #009de9));
  1585. box-shadow: 0 0 6px rgba(1, 171, 234, 0.35);
  1586. }
  1587. .panel-trend__count {
  1588. color: var(--text);
  1589. font-family: var(--num, monospace);
  1590. font-size: var(--fs-caption, 10.5px);
  1591. line-height: 1;
  1592. }
  1593. .panel-trend__label {
  1594. max-width: 100%;
  1595. color: var(--muted, #5680b0);
  1596. font-size: var(--fs-caption, 11.5px);
  1597. line-height: 1.1;
  1598. }
  1599. /* 票 14:状态分布饼图。左图右例;未选中的扇区在"有选中"时降透明度(高亮当前选中)。 */
  1600. .panel-pie {
  1601. display: flex;
  1602. align-items: center;
  1603. gap: var(--space-3, 12px);
  1604. }
  1605. .panel-pie__chart {
  1606. flex: none;
  1607. width: calc(64 * var(--u, 1px));
  1608. height: calc(64 * var(--u, 1px));
  1609. }
  1610. .panel-pie__slice {
  1611. stroke: rgba(2, 10, 20, 0.6);
  1612. stroke-width: 1;
  1613. transition: opacity 0.16s;
  1614. }
  1615. .panel-pie__slice.is-dim {
  1616. opacity: 0.3;
  1617. }
  1618. .panel-pie__legend {
  1619. display: grid;
  1620. gap: 1px;
  1621. margin: 0;
  1622. padding: 0;
  1623. list-style: none;
  1624. min-width: 0;
  1625. }
  1626. .panel-pie__row {
  1627. display: flex;
  1628. align-items: center;
  1629. /* 票 43(重开):数值紧跟标签成对(原 space-between 把数字顶到最右,
  1630. 296px 面板里标签与数值隔整行宽,没有关联感)。与堆叠图例同一贴法。 */
  1631. gap: var(--space-2, 8px);
  1632. min-width: 0;
  1633. border-radius: 2px;
  1634. }
  1635. .panel-pie__row.is-selected .panel-pie__count {
  1636. color: var(--text);
  1637. text-shadow: 0 0 8px rgba(1, 171, 234, 0.6);
  1638. }
  1639. .panel-pie__count {
  1640. flex: none;
  1641. color: var(--text);
  1642. font-family: var(--num, monospace);
  1643. font-size: var(--fs-caption, 10.5px);
  1644. }
  1645. .panel-rows {
  1646. display: grid;
  1647. gap: calc(2 * var(--u, 1px));
  1648. margin: 0;
  1649. }
  1650. .panel-row {
  1651. display: grid;
  1652. grid-template-columns: calc(64 * var(--u, 1px)) minmax(0, 1fr);
  1653. align-items: baseline;
  1654. gap: var(--space-2, 8px);
  1655. border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
  1656. }
  1657. /* 票 55:rows 口径块与共享 bars/donut 图例统一字号、行高和间距。
  1658. 只作用于 rows renderer,避免把面板正文或其它块的字号一起放大/缩小。 */
  1659. .panel-row dt,
  1660. .panel-row dd {
  1661. font-size: var(--fs-caption, 10.5px);
  1662. line-height: 1.35;
  1663. }
  1664. .panel-row dt {
  1665. color: var(--muted, #5680b0);
  1666. }
  1667. .panel-row dd {
  1668. margin: 0;
  1669. color: var(--text, #d7ecff);
  1670. word-break: break-word;
  1671. }
  1672. .panel-list {
  1673. display: grid;
  1674. gap: calc(4 * var(--u, 1px));
  1675. }
  1676. /* 票 40:清单卡升级为「事件卡片」形态(样式参考当前预警面板的事件卡;维护者:
  1677. 卡片高度不需要那么高,主要参考设计及交互效果)。独立卡框 + 内衬底 + 紧凑两列网格,
  1678. 比事件卡更矮(行距与内边距再收一档)。渲染形态只有一份 —— 应急保障/管理单位等
  1679. 同一 list 块的卡片一并升级,不为单个面板长第二套样式。 */
  1680. .panel-item {
  1681. display: grid;
  1682. grid-template-columns: repeat(2, minmax(0, 1fr));
  1683. gap: calc(2 * var(--u, 1px)) calc(8 * var(--u, 1px));
  1684. align-items: start;
  1685. padding: calc(4 * var(--u, 1px)) calc(6 * var(--u, 1px));
  1686. border: 1px solid var(--panel-line, rgba(1, 171, 234, 0.35));
  1687. border-radius: 4px;
  1688. background: var(--inset, rgba(0, 31, 92, 0.5));
  1689. min-width: 0;
  1690. }
  1691. /* 可点卡(台账有 code、异常设备有定位点):交互语言与事件卡同一套 ——
  1692. hover/focus 高亮边框、底色抬亮、左缘压 3px 竖条。 */
  1693. .panel-item--clickable {
  1694. cursor: pointer;
  1695. transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
  1696. }
  1697. .panel-item--clickable:hover,
  1698. .panel-item--clickable:focus-within {
  1699. border-color: var(--cyan-hi, #00b8ff);
  1700. background: rgba(0, 82, 168, 0.5);
  1701. box-shadow: inset 3px 0 0 var(--cyan-hi, #00b8ff);
  1702. }
  1703. .panel-item__head {
  1704. grid-column: 1 / -1;
  1705. display: flex;
  1706. align-items: center;
  1707. justify-content: space-between;
  1708. gap: var(--space-2, 8px);
  1709. }
  1710. /* 原因/徽标/定位按钮行横贯整卡;两条 meta(说明与数据时间)并排一行收矮卡高。 */
  1711. .panel-item__reason,
  1712. .panel-item__badge,
  1713. .panel-item__points {
  1714. grid-column: 1 / -1;
  1715. }
  1716. .panel-item__title {
  1717. min-width: 0;
  1718. color: var(--text, #d7ecff);
  1719. font-size: var(--fs-caption, 10.5px);
  1720. }
  1721. /* 台账卡片标题是可点按钮:外观与文字标题一致,但可聚焦、有 hover。 */
  1722. .panel-item__title--button {
  1723. padding: 0;
  1724. border: 0;
  1725. background: transparent;
  1726. font: inherit;
  1727. font-size: var(--fs-caption, 10.5px);
  1728. text-align: left;
  1729. cursor: pointer;
  1730. }
  1731. .panel-item__title--button:hover,
  1732. .panel-item__title--button:focus-visible {
  1733. color: var(--cyan, #01abea);
  1734. }
  1735. .panel-item__title--button:focus-visible {
  1736. outline: 1px solid var(--cyan, #01abea);
  1737. outline-offset: 1px;
  1738. }
  1739. .panel-item__meta {
  1740. margin: 0;
  1741. color: var(--text-2, #88b7e8);
  1742. font-size: var(--fs-caption, 10.5px);
  1743. }
  1744. /* 状态原因与徽标:低一档亮度,不跟标题抢注意力。 */
  1745. .panel-item__reason,
  1746. .panel-item__badge {
  1747. color: var(--muted, #5680b0);
  1748. font-size: var(--fs-caption, 10.5px);
  1749. }
  1750. .panel-item__points {
  1751. display: flex;
  1752. flex-wrap: wrap;
  1753. gap: var(--space-1, 4px);
  1754. }
  1755. /* 「加载下一页」:与面板内其它按钮同一套视觉。 */
  1756. .panel-more {
  1757. justify-self: start;
  1758. padding: 1px var(--space-2, 8px);
  1759. border: 1px solid rgba(1, 171, 234, 0.4);
  1760. border-radius: calc(8 * var(--u, 1px));
  1761. background: rgba(2, 9, 27, 0.6);
  1762. color: var(--text-2, #88b7e8);
  1763. font: inherit;
  1764. font-size: var(--fs-caption, 10.5px);
  1765. cursor: pointer;
  1766. }
  1767. .panel-more:hover,
  1768. .panel-more:focus-visible {
  1769. border-color: var(--cyan, #01abea);
  1770. color: var(--text);
  1771. }
  1772. /* 可定位按钮:小、可点、有 hover 反馈(它是面板与地图之间唯一的交互面)。 */
  1773. .panel-locate {
  1774. padding: 1px var(--space-2, 8px);
  1775. border: 1px solid rgba(1, 171, 234, 0.45);
  1776. border-radius: calc(8 * var(--u, 1px));
  1777. background: rgba(2, 9, 27, 0.6);
  1778. color: var(--cyan, #01abea);
  1779. font: inherit;
  1780. font-size: var(--fs-caption, 10.5px);
  1781. letter-spacing: 0.04em;
  1782. white-space: nowrap;
  1783. cursor: pointer;
  1784. }
  1785. .panel-locate:hover,
  1786. .panel-locate:focus-visible {
  1787. border-color: var(--cyan, #01abea);
  1788. background: rgba(1, 171, 234, 0.14);
  1789. color: var(--text);
  1790. }
  1791. .panel-block__empty,
  1792. .panel-block__page-note,
  1793. .panel-blocks__placeholder,
  1794. .panel-blocks__missing,
  1795. .is-muted {
  1796. margin: 0;
  1797. color: var(--muted, #5680b0);
  1798. font-size: var(--fs-caption, 10.5px);
  1799. letter-spacing: 0.04em;
  1800. }
  1801. .panel-blocks__missing {
  1802. color: var(--lv2, #ffb020);
  1803. }
  1804. /* ================================================================
  1805. 图表 hover 交互(2026-10-14 维护者走查要求:悬停放大 + 数据详情浮层)。
  1806. 放大 = 各图自己的一档 transform(过渡 0.15s,出离即还原);
  1807. 详情 = 面板内一个共享浮层(.chart-tip),锚在悬停元素上方居中。
  1808. ================================================================ */
  1809. /* 折线数据点:点放大 + 光晕增强(数据点是 5u 的小圆,放大 1.8 倍才可感)。 */
  1810. .panel-trend__node {
  1811. cursor: default;
  1812. transition: transform 0.15s;
  1813. }
  1814. .panel-trend__node:hover {
  1815. transform: translate(-50%, 50%) scale(1.8);
  1816. }
  1817. .panel-trend__node:hover .panel-trend__dot {
  1818. box-shadow: 0 0 10px rgba(1, 171, 234, 1);
  1819. }
  1820. /* 柱列形态:柱体增亮 + 顶部上浮一档(柱是 flex 子项,scale 会糊相邻柱,改用亮度与位移)。 */
  1821. .panel-trend__point {
  1822. cursor: default;
  1823. }
  1824. .panel-trend__point:hover .panel-trend__bar {
  1825. background: linear-gradient(180deg, var(--cyan-hi, #00b8ff), var(--cyan, #01abea));
  1826. box-shadow: 0 0 10px rgba(1, 171, 234, 0.8);
  1827. transform: translateY(-1px);
  1828. }
  1829. /* 堆叠条分段:悬停时实际放大(不挤邻居),并以亮度/描边强化反馈。
  1830. 父条不裁切,避免 scaleY 被吞;分段自身 z-index 覆盖相邻段。 */
  1831. .panel-stacked__seg {
  1832. cursor: default;
  1833. transform-origin: center;
  1834. transition: transform 0.15s, filter 0.15s, box-shadow 0.15s;
  1835. }
  1836. .panel-stacked__seg:hover {
  1837. transform: scaleY(1.8) scaleX(1.04);
  1838. filter: brightness(1.35) saturate(1.2);
  1839. box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.7), 0 0 8px var(--seg-color, var(--cyan, #01abea));
  1840. z-index: 1;
  1841. }
  1842. /* 环形图扇区:沿圆心向外放大(transform-origin 在 SVG path 上按各自中点近似取中心即可)。
  1843. 票 43(重开):放大/增亮改由 `.is-hovered` 驱动(联动状态),不再是扇区自己的 `:hover` ——
  1844. 指针事件现在落在透明命中楔形上,可见扇区收不到 hover;命中区与图例行都写同一个联动状态,
  1845. 所以「悬停扇区 ⇄ 悬停图例」两侧同时亮(对齐横条统计的整行热区手感)。
  1846. 票 43(第三轮):放大档从 1.06 加到 **1.1 + 提亮 1.35 + 白描边**——维护者反馈
  1847. 「只出详情、没有放大」;除联动状态本身失效(shallowRef 根因)外,1.06 在 72px 图上
  1848. 只有 ~2px 位移也不够"明显"。命中楔形:透明填充、只接收指针;键盘焦点环画在它身上。 */
  1849. .panel-donut__slice {
  1850. transition: transform 0.15s, filter 0.15s;
  1851. transform-origin: 50px 50px;
  1852. transform-box: view-box;
  1853. }
  1854. .panel-donut__slice.is-hovered {
  1855. transform: scale(1.1);
  1856. filter: brightness(1.35);
  1857. stroke: rgba(255, 255, 255, 0.9);
  1858. stroke-width: 1.5;
  1859. }
  1860. .panel-donut__hit {
  1861. fill: transparent;
  1862. cursor: default;
  1863. stroke: none;
  1864. outline: none;
  1865. transform-box: view-box;
  1866. }
  1867. .panel-donut__hit:focus-visible {
  1868. stroke: rgba(255, 255, 255, 0.85);
  1869. stroke-width: 1.5;
  1870. }
  1871. /* 饼图扇区:同环形(票 14 的 is-dim 选中降透明保留;opacity 过渡不能丢)。
  1872. 票 43(第三轮):放大档同环形加到 1.1 + 提亮 + 白描边(64px 图上 1.05 只有 ~1.5px,不够明显)。 */
  1873. .panel-pie__slice {
  1874. transition: transform 0.15s, filter 0.15s, opacity 0.16s;
  1875. transform-origin: 50px 50px;
  1876. transform-box: view-box;
  1877. }
  1878. .panel-pie__slice.is-hovered {
  1879. transform: scale(1.1);
  1880. filter: brightness(1.35);
  1881. stroke: rgba(255, 255, 255, 0.9);
  1882. stroke-width: 1.5;
  1883. }
  1884. .panel-pie__hit {
  1885. fill: transparent;
  1886. cursor: default;
  1887. stroke: none;
  1888. outline: none;
  1889. transform-box: view-box;
  1890. }
  1891. .panel-pie__hit:focus-visible {
  1892. stroke: rgba(255, 255, 255, 0.85);
  1893. stroke-width: 1.5;
  1894. }
  1895. /* 票 43(重开):图例行联动高亮(与扇区双向)——底色一档 + 文字提亮,
  1896. 与横条统计 `.panel-bar:hover .panel-bar__fill` 同一档可感度。 */
  1897. .panel-donut__row.is-hovered,
  1898. .panel-pie__row.is-hovered {
  1899. background: rgba(1, 171, 234, 0.12);
  1900. }
  1901. .panel-donut__row.is-hovered .panel-donut__label,
  1902. .panel-pie__row.is-hovered .panel-pie__count {
  1903. color: var(--text-strong, #fff);
  1904. }
  1905. /* 条形图行:填充条增亮(行高太小,不位移)。 */
  1906. .panel-bar__fill {
  1907. transition: filter 0.15s;
  1908. }
  1909. .panel-bar:hover .panel-bar__fill {
  1910. filter: brightness(1.3);
  1911. }
  1912. /* 共享详情浮层:锚点在悬停元素上缘中点,自身再向上偏移并水平居中。
  1913. pointer-events:none —— 浮层不拦截鼠标,移开即消失;z-index 盖过图表但不盖面板头。 */
  1914. .chart-tip {
  1915. position: absolute;
  1916. z-index: 5;
  1917. display: flex;
  1918. align-items: center;
  1919. gap: calc(5 * var(--u, 1px));
  1920. padding: calc(3 * var(--u, 1px)) var(--space-2, 8px);
  1921. border: 1px solid var(--tip-color, var(--cyan, #01abea));
  1922. border-radius: calc(8 * var(--u, 1px));
  1923. background: rgba(2, 10, 24, 0.92);
  1924. box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5), 0 0 8px color-mix(in srgb, var(--tip-color, #01abea) 55%, transparent);
  1925. transform: translate(-50%, calc(-100% - 6px));
  1926. pointer-events: none;
  1927. white-space: nowrap;
  1928. }
  1929. .chart-tip__swatch {
  1930. flex: none;
  1931. width: calc(7 * var(--u, 1px));
  1932. height: calc(7 * var(--u, 1px));
  1933. background: var(--tip-color, var(--cyan, #01abea));
  1934. }
  1935. .chart-tip__label {
  1936. color: var(--text-2, #88b7e8);
  1937. font-size: var(--fs-caption, 10.5px);
  1938. }
  1939. .chart-tip__value {
  1940. color: var(--text-strong, #fff);
  1941. font-family: var(--num, monospace);
  1942. font-size: var(--fs-caption, 10.5px);
  1943. }
  1944. </style>