CockpitShell.vue 135 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818
  1. <!--
  2. 驾驶舱外壳:唯一 /cockpit 路由的全部内容。
  3. 骨架与视觉参考《驾驶舱原型图-美化版》(切角面板 / 青色辉光 / 深空蓝底 / 全息地图壳),
  4. 但不锁 16:9:页面铺满视口,**中央一张图作为整个主体的背景铺满**,左右两栏浮在图上。
  5. 语义按 ADR 0003 / 0007:四个一级模式、模式内 4 个二级面板同屏展示、无子视图切换。
  6. 一级模式只通过 uiStore.mode 切换,不新增路由、不重建外壳与地图节点。
  7. 范围与行业是全局筛选(filterStore),切换一级模式不得重置它们(验收第 ⑦ 条)。
  8. -->
  9. <template>
  10. <div
  11. class="cockpit-shell"
  12. :data-font-gate="fontGate"
  13. :data-font-faces="fontFaceCount"
  14. >
  15. <div class="cockpit-shell__atmosphere" aria-hidden="true"></div>
  16. <header class="cockpit-shell__topbar" :style="navDecorStyle">
  17. <div class="cockpit-shell__topbar-bg" aria-hidden="true"></div>
  18. <!-- 票 17 第十一轮:底边光路改为**裁切窗口**——只显示切图中段线段(画布列 22.9%–62%),
  19. 从标题右侧起铺到屏幕右缘。此前整图拉伸 100% 宽,线段按百分比映射视口,
  20. 起点永远落在标题上(挪图片盒子无济于事)。URL 经语义 key 注入(navDecorStyle)。 -->
  21. <div class="cockpit-shell__topbar-path" aria-hidden="true"></div>
  22. <span class="cockpit-shell__wing is-left" aria-hidden="true"></span>
  23. <span class="cockpit-shell__wing is-right" aria-hidden="true"></span>
  24. <div class="cockpit-shell__brand">
  25. <!--
  26. 票 17 第六轮(维护者:要符合切图素材风格,把 brand-glow / brand-glow-path 用上):
  27. 标题回到蓝湖切图 `Group 5061`(渐变文字 + 底座光带,设计原样)。
  28. 第五轮"太小"的真因是**按顶栏高度等比缩放**——切图 470×114 里文字只占上部 53.5%,
  29. 等比进 52u 顶栏后文字仅 29u。现在**按文字高度反推整图宽**(宽 313u → 高 76u → 文字 ≈41u),
  30. 图片顶部对齐(不居中,切图文字从画布第 0 行开始,居中会裁掉字顶),
  31. 底部淡晕溢出顶栏、由下方筛选条自然承接——正是设计稿里光层(114)比文字(61)高的关系。
  32. -->
  33. <CockpitIcon class="cockpit-shell__mark" name="brand" size="calc(404 * var(--u))" />
  34. <strong class="cockpit-shell__sr-only">城市生命线驾驶舱系统</strong>
  35. </div>
  36. <nav class="cockpit-shell__modes" aria-label="一级模式">
  37. <button
  38. v-for="item in MODES"
  39. :key="item"
  40. type="button"
  41. class="cockpit-shell__mode"
  42. :class="{ 'is-active': item === mode }"
  43. :aria-pressed="item === mode"
  44. @click="uiStore.setMode(item)"
  45. >
  46. {{ modeLabel(item) }}
  47. </button>
  48. </nav>
  49. <div class="cockpit-shell__top-meta">
  50. <!--
  51. 票 30:快速归位(回到目标城市预设视角)。
  52. 票 37:区内/区外文案不同 —— 区内是「归位」(已进区,只是回该视角);
  53. 区外是「归位/进入」(点它就进区并锁定,ADR 0009 R2-5)。
  54. -->
  55. <button
  56. type="button"
  57. class="cockpit-shell__chip cockpit-shell__home"
  58. :class="{ 'is-active': atHomeView }"
  59. :data-home-at="atHomeView ? 'yes' : 'no'"
  60. :data-area-locked="areaLocked ? 'yes' : 'no'"
  61. :title="homeTitle"
  62. :aria-label="areaBar.homeLabel"
  63. @click="returnHome()"
  64. >
  65. {{ areaBar.homeLabel }}
  66. </button>
  67. <!-- 票 37:只有区内才有「返回全球」(区外它没有意义,不占位置)。 -->
  68. <button
  69. v-if="areaBar.returnGlobal"
  70. type="button"
  71. class="cockpit-shell__chip cockpit-shell__return-global"
  72. data-return-global
  73. :title="`解除主场景区限位,回到自由全球视角(区内最远 ${Math.round(areaLimits.max)} m)`"
  74. @click="returnToGlobal()"
  75. >
  76. 返回全球
  77. </button>
  78. <!-- 票 04:配置三态 + 数据源,顶栏可演示;data-* 供走查断言。
  79. 票 32:这是开发期的诊断读数,生产形态不占顶栏(状态退到底部小字)。 -->
  80. <span
  81. v-if="configLoaded && uiDebug"
  82. class="cockpit-shell__config"
  83. :data-config-state="configView.state"
  84. :data-data-source="configView.dataSource"
  85. :data-source="configState.source"
  86. >
  87. <StatusTag :tone="configView.tone" :label="configView.label" />
  88. </span>
  89. <span class="cockpit-shell__chip">只读视图</span>
  90. <span class="cockpit-shell__clock">{{ clockText }}</span>
  91. <p v-if="uiDebug" class="cockpit-shell__meta">当前模式 {{ mode }}</p>
  92. </div>
  93. </header>
  94. <!-- 全局筛选:作用于整个驾驶舱,切换一级模式不重置 -->
  95. <div class="cockpit-shell__scope">
  96. <div class="cockpit-shell__scope-left">
  97. <span class="cockpit-shell__scope-label">当前范围</span>
  98. <div class="cockpit-shell__chips">
  99. <button
  100. v-for="item in SCOPES"
  101. :key="item.key"
  102. type="button"
  103. class="cockpit-shell__chip cockpit-shell__scope-btn"
  104. :class="{ 'is-active': item.key === scope }"
  105. :aria-pressed="item.key === scope"
  106. :data-scope="item.key"
  107. @click="filterStore.setScope(item.key)"
  108. >
  109. {{ item.label }}
  110. </button>
  111. </div>
  112. <span class="cockpit-shell__divider" aria-hidden="true"></span>
  113. <span class="cockpit-shell__scope-label">行业</span>
  114. <div class="cockpit-shell__chips">
  115. <button
  116. v-for="item in industryChips"
  117. :key="item.key"
  118. type="button"
  119. class="cockpit-shell__chip cockpit-shell__industry-btn"
  120. :class="{ 'is-active': item.key === industry }"
  121. :aria-pressed="item.key === industry"
  122. :data-industry="item.key"
  123. @click="filterStore.setIndustry(item.key)"
  124. >
  125. <!-- 批次 1:行业图标,纯装饰;aria-hidden 不参与按钮命名。 -->
  126. <CockpitIcon v-if="item.icon" :name="item.icon" :size="industryIconSize" class="cockpit-shell__industry-icon" />
  127. {{ item.label }}
  128. </button>
  129. </div>
  130. <span class="cockpit-shell__divider" aria-hidden="true"></span>
  131. <!--
  132. 票 21:地面/管线视图(决策地图票 05 定的「全局视图条件」)。
  133. 它是全局条件,与范围/行业同级 —— 切换一级模式不重置它。
  134. -->
  135. <span class="cockpit-shell__scope-label">视图</span>
  136. <div class="cockpit-shell__chips">
  137. <button
  138. v-for="item in VIEW_MODE_OPTIONS"
  139. :key="item.key"
  140. type="button"
  141. class="cockpit-shell__chip cockpit-shell__view-btn"
  142. :class="{ 'is-active': item.key === viewMode }"
  143. :aria-pressed="item.key === viewMode"
  144. :data-view-mode="item.key"
  145. :title="item.hint"
  146. @click="switchViewMode(item.key)"
  147. >
  148. {{ item.label }}
  149. </button>
  150. </div>
  151. </div>
  152. <div class="cockpit-shell__scope-right">
  153. <div
  154. class="cockpit-shell__status-summary"
  155. :class="{ 'is-open': statusPanelExpanded }"
  156. :data-status-expanded="statusPanelExpanded ? 'true' : 'false'"
  157. >
  158. <button
  159. type="button"
  160. class="cockpit-shell__status-toggle"
  161. data-status-toggle
  162. :aria-expanded="statusPanelExpanded"
  163. aria-controls="cockpit-status-details"
  164. @click="statusPanelExpanded = !statusPanelExpanded"
  165. >
  166. <i class="cockpit-shell__status-dot" :class="statusSummaryTone" aria-hidden="true"></i>
  167. <span data-status-summary>{{ statusSummaryText }}</span>
  168. <span class="cockpit-shell__status-chevron" aria-hidden="true">{{ statusPanelExpanded ? '⌃' : '⌄' }}</span>
  169. </button>
  170. <div
  171. id="cockpit-status-details"
  172. v-show="statusPanelExpanded"
  173. class="cockpit-shell__status-details"
  174. data-status-details
  175. >
  176. <!--
  177. 票 14(决策 02 的 Q24/Q25):地图摘要进入系统/筛选变化后**自动分批加载到完成**。
  178. 它作为状态详情的一行保留 `data-map-summary-*` 断言面;手动刷新按钮移到状态摘要
  179. 右侧独立位置,避免和范围/行业/视图选择争抢横向空间。
  180. -->
  181. <div
  182. class="cockpit-shell__map-summary"
  183. :data-map-summary-phase="mapSummaryView.phase"
  184. role="status"
  185. aria-label="地图摘要"
  186. >
  187. <i class="cockpit-shell__map-summary-dot" aria-hidden="true"></i>
  188. <span class="cockpit-shell__map-summary-text" data-map-summary-label :title="mapSummaryView.detail">
  189. {{ mapSummaryView.label }}
  190. </span>
  191. </div>
  192. <!--
  193. 票 05:共享数据分区的状态板。验收第 6 条要的「只把受影响分区标为数据不可用、
  194. 保留已成功分区与地图骨架」在这里可见;每个分区带 data-section-* 供走查断言。
  195. 票 14(规格 09):real 与 mock **同构启动**——同一套分区语义对两种数据源都成立,
  196. real 的失败路径(不可达 / 未配地址)同样只标对应分区。
  197. -->
  198. <span class="cockpit-shell__scope-label">数据分区</span>
  199. <div class="cockpit-shell__sections" data-section-board>
  200. <SectionStateTag
  201. v-for="item in sharedSections"
  202. :key="item.name"
  203. :name="item.name"
  204. :section="item.section"
  205. :label="item.label"
  206. />
  207. </div>
  208. <!--
  209. 票 25:地图信息并入**本状态面板**,不再另起容器。
  210. 维护者反馈过另起面板会与顶部图例、本面板互相占用容器(图一 vs 图二)。
  211. 内容含:当前模式、降级说明、图层、标记计数、坐标准入明细、引擎读数、
  212. 数据来源署名(Cesium 自带署名已移出画面,见 cesiumEngine.js)。
  213. -->
  214. <MapInfoPanel
  215. class="cockpit-shell__map-info"
  216. :mode-label="modeLabel(mode)"
  217. :ui-debug="uiDebug"
  218. :degraded-notes="modeLayerPlan.degradedNotes"
  219. :marker-on-map="mapMarkerCount"
  220. :marker-status-text="mapMarkerStatusText"
  221. :coordinate-overview="mapCoordinateOverview"
  222. :basemap-label="mapBasemapLabel"
  223. :credits="mapDataSourceCredits"
  224. />
  225. <!--
  226. 票 67(含维护者 2026-10-23 更正):「当前范围」对管线/构件图层的真实落点。
  227. 几何 properties 里确实没有片区字段,但**设施编码**的规则是「片区+行业+时间编码」
  228. (TCRQ... = 太常燃气),所以片区能从编码前缀推出来并按它裁剪图层
  229. (domain/pipeNetwork.js 的 pipeDistrictOf)。这条说明讲的就是「片区是推出来的」。
  230. 措辞来自 domain/filterCaliber.js 的单一来源,外壳不另写一份。
  231. -->
  232. <span class="cockpit-shell__chip cockpit-shell__pipe-scope" data-pipe-district-note role="note">
  233. {{ PIPE_DISTRICT_NOTE }}
  234. </span>
  235. <!--
  236. 票 17:定位反馈。**票 38(第 5 条)把它从这块折叠详情里挪走了**——
  237. 「状态详情」默认收起,失败提示藏在里面等于「点了没反应」;
  238. 现在它是主体区底部的浮层(见 cockpit-shell__notice),这里只留注释锚点。
  239. -->
  240. <span v-if="detailOpen && !introVisible && !fixedLocationReason && !anchorLayout.visible"
  241. class="cockpit-shell__chip cockpit-shell__anchor-status" data-anchor-hidden
  242. :data-anchor-reason="anchorLayout.reason" role="status">
  243. {{ selectedDetailCode }} · {{ hiddenAnchorText }}
  244. <button v-if="canRelocate" type="button" data-anchor-relocate @click="relocateSelected">重新定位</button>
  245. <button type="button" data-anchor-close @click="detailStore.closeDetail()">关闭</button>
  246. </span>
  247. <!--
  248. 票 42(规格 14:刷新核对链):换新快照期间设备图层已挂起,详情进入「待核对」;
  249. 顶栏如实提示并保留关闭入口(关闭按钮在上方 data-anchor-hidden chip 里)。
  250. -->
  251. <span v-if="snapshotPending && detailOpen"
  252. class="cockpit-shell__chip cockpit-shell__anchor-status" data-snapshot-pending role="status">
  253. 快照核对中 · 关联设备图层已暂撤
  254. </span>
  255. <!--
  256. 票 42:完整加载仍找不到所选点位 → 详情已按规格关闭,并说明原因。
  257. -->
  258. <span v-if="snapshotAbsentNote" class="cockpit-shell__chip cockpit-shell__locate" data-snapshot-absent role="status">
  259. {{ snapshotAbsentNote }}
  260. </span>
  261. <!--
  262. 票 37:进区受阻时的如实提示(主场景区未配置 / 配置非法)。
  263. 与票 04/05 同一条立场:能力不可用只标出来,不弹窗、不遮罩、不挡业务。
  264. -->
  265. <span v-if="areaNotice" class="cockpit-shell__chip cockpit-shell__locate" data-area-notice role="status">
  266. {{ areaNotice }}
  267. </span>
  268. <!--
  269. 票 15:状态推送的连接状态(验收第 6 条「连接/重连/过期/失败可演示」)。
  270. data-ws-state 是走查断言面;文案来自运行时服务,外壳不重写。
  271. -->
  272. <span
  273. class="cockpit-shell__chip cockpit-shell__ws"
  274. :class="{ 'is-bad': !runtimeStore.live }"
  275. :data-ws-state="runtimeStore.status.state"
  276. :title="runtimeStore.status.lastError || runtimeStore.statusText"
  277. role="status"
  278. >
  279. {{ runtimeStore.statusText }}
  280. <span v-if="runtimeStore.orderScanIntervalMs" data-order-scan-delay> · 工单扫描间隔 {{ runtimeStore.orderScanIntervalMs / 1000 }} 秒</span>
  281. </span>
  282. <!--
  283. 票 16:统一新鲜度 —— 「最近一次成功更新」+ 四态。
  284. 过期时**必须**看得见(验收第 4 条:保留旧值但不伪装成实时),
  285. 所以文案与色键都来自 domain/freshnessModel.js,外壳不重写。
  286. data-freshness / data-freshness-at 是走查断言面。
  287. -->
  288. <span
  289. class="cockpit-shell__chip cockpit-shell__freshness"
  290. :class="{ 'is-stale': runtimeStore.retained, 'is-bad': runtimeStore.freshness.view === 'unavailable' }"
  291. :data-freshness="runtimeStore.freshness.view"
  292. :data-freshness-at="runtimeStore.freshness.lastSuccessClock"
  293. :data-freshness-overall="overallFreshnessState"
  294. :title="runtimeStore.freshness.reason || runtimeStore.freshness.text"
  295. role="status"
  296. >
  297. 更新 {{ runtimeStore.freshness.lastSuccessClock }} · {{ runtimeStore.freshness.text }}
  298. <!--
  299. 票 16 验收第 2 条:**部分失败**是跨分区的性质,单块面板只会说自己那一份,
  300. 所以在这里补一句整体结论(判据来自 domain/freshnessModel.js,界面不数数)。
  301. -->
  302. <b v-if="overallFreshnessState === 'partial'" class="cockpit-shell__freshness-extra" data-freshness-partial>
  303. · {{ overallFreshnessLabel }}
  304. </b>
  305. </span>
  306. </div>
  307. </div>
  308. </div>
  309. <button
  310. type="button"
  311. class="cockpit-shell__chip cockpit-shell__refresh-action"
  312. data-map-summary-action
  313. data-map-refresh
  314. :disabled="mapStore.busy"
  315. aria-label="手动刷新快照"
  316. title="换到后端最新快照,并自动从第 1 页加载到完成"
  317. @click="refreshMapSnapshot"
  318. >
  319. <span aria-hidden="true" class="cockpit-shell__refresh-glyph">↻</span>
  320. </button>
  321. </div>
  322. <main class="cockpit-shell__main">
  323. <!--
  324. 中央一张图:铺满整个主体背景。常驻——不放在 v-for 内,也不加 v-if/key,
  325. 一级模式切换只重渲染左右两栏,本节点保持原实例。
  326. -->
  327. <section class="cockpit-shell__map" aria-label="中央一张图">
  328. <!--
  329. 票 46:模式图层计划在**外壳**算一次(外壳握有设备行/异常设备/选择态全部输入),
  330. 地图组件只消费计划 —— 模式过滤在 `domain/modeLayerPlan.js` 收口,无第二次过滤。
  331. 引导态(`introVisible`)时计划输出全空:无标记、无图例、无计数浮标、无详情;
  332. 地图摘要仍可预取(mapStore 照常加载)。
  333. -->
  334. <MapCanvas
  335. :mode-label="modeLabel(mode)"
  336. :ui-debug="uiDebug"
  337. :plan="modeLayerPlan"
  338. @pipeline-pick="selectPipeEnhancement"
  339. />
  340. <!--
  341. 票 48:管线颜色图例——中央「一张图」**正下方、水平居中、持久显示**
  342. (不随模式切换/筛选/悬停消失;与顶部状态图例是两条不同的东西,不合并不替换)。
  343. 计数 = `pipeNetworkStore` **全量**口径(不随行业筛选变化,对齐顶部状态图例的
  344. 全局未筛口径立场);图层还没取数时 counts.available=false → 不画计数,不补 0。
  345. 放在地图段内(而非 main 顶层):局部增强窗是本段的兄弟且层级更高,
  346. 窗开着时把图例一并盖住——图例描述的是被窗遮住的那些管线,此刻不该浮在窗上。
  347. -->
  348. <PipeColorLegend :counts="pipeColorCountsView" />
  349. </section>
  350. <PipeEnhancement
  351. class="cockpit-shell__enhancement"
  352. :plan="pipeEnhancementPlan"
  353. :device-state="enhancementDevicesStore"
  354. @object-pick="selectPipeEnhancement"
  355. @device-pick="enhancementDevicesStore.open"
  356. @devices-reload="enhancementDevicesStore.load(mapStore.summary, { force: true })"
  357. @device-close="enhancementDevicesStore.closeDetail"
  358. @device-retry="enhancementDevicesStore.open(enhancementDevicesStore.selected)"
  359. @radius-change="pipeEnhancementStore.setRadius"
  360. @close="closePipeEnhancement"
  361. />
  362. <aside class="cockpit-shell__column is-left" aria-label="左栏">
  363. <CockpitPanel
  364. v-for="panelId in columns.left"
  365. :key="panelId"
  366. :panel-id="panelId"
  367. :title="panelTitle(panelId)"
  368. :meta="panelMeta(panelId)"
  369. :section="sectionOf(panelId)"
  370. :expanded="isExpanded(panelId)"
  371. @toggle="uiStore.togglePanel(panelId)"
  372. >
  373. <PanelBlocks
  374. v-if="hasPanelView(panelId) && !['currentEvents', 'historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)"
  375. :view="panelViewOf(panelId)"
  376. @locate="locateOnMap"
  377. @open-card="openLedgerCard"
  378. @badge-needed="loadLedgerBadge"
  379. @panel-action="handlePanelAction"
  380. />
  381. <!--
  382. 票 13:当前预警面板挂**事件列表 + 预警/报警切换**(可复用组件)。
  383. 它是自己的组件而不是 PanelBlocks 的块:事件列表的形状(分段切换 +
  384. 表格行)不在 kpi/bars/rows/list 四种块画法里,硬塞进共享渲染层会
  385. 让 PanelBlocks 认识事件字段(票 18 解除的耦合又长回来)。
  386. 票 19 的另外三块面板复用它(同一个 eventStore.mode 切换状态)。
  387. -->
  388. <EventListPane v-else-if="panelId === 'currentEvents'" />
  389. <WarningResponsePane v-else-if="['historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)" :panel-id="panelId" />
  390. <PanelPlaceholder v-else :panel-id="panelId" />
  391. </CockpitPanel>
  392. </aside>
  393. <aside class="cockpit-shell__column is-right" aria-label="右栏">
  394. <CockpitPanel
  395. v-for="panelId in columns.right"
  396. :key="panelId"
  397. :panel-id="panelId"
  398. :title="panelTitle(panelId)"
  399. :meta="panelMeta(panelId)"
  400. :section="sectionOf(panelId)"
  401. :expanded="isExpanded(panelId)"
  402. @toggle="uiStore.togglePanel(panelId)"
  403. >
  404. <PanelBlocks
  405. v-if="hasPanelView(panelId) && !['currentEvents', 'historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)"
  406. :view="panelViewOf(panelId)"
  407. @locate="locateOnMap"
  408. @open-card="openLedgerCard"
  409. @badge-needed="loadLedgerBadge"
  410. @panel-action="handlePanelAction"
  411. />
  412. <!--
  413. 票 13:当前预警面板挂**事件列表 + 预警/报警切换**(可复用组件)。
  414. 它是自己的组件而不是 PanelBlocks 的块:事件列表的形状(分段切换 +
  415. 表格行)不在 kpi/bars/rows/list 四种块画法里,硬塞进共享渲染层会
  416. 让 PanelBlocks 认识事件字段(票 18 解除的耦合又长回来)。
  417. 票 19 的另外三块面板复用它(同一个 eventStore.mode 切换状态)。
  418. -->
  419. <EventListPane v-else-if="panelId === 'currentEvents'" />
  420. <WarningResponsePane v-else-if="['historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)" :panel-id="panelId" />
  421. <PanelPlaceholder v-else :panel-id="panelId" />
  422. </CockpitPanel>
  423. </aside>
  424. <!--
  425. 票 10:两级详情浮层(点地图上的监测点打开)。浮在图上、两栏之间;
  426. 它是 `<main>` 的兄弟而不是地图节点的子节点 —— MapCanvas 仍是常驻的那个实例。
  427. 位置与宽度由本外壳给(组件自己不假设身处哪里,票 18 的台账面板可复用同一个卡片)。
  428. 票 46:**选择态暂停**(规格 18)—— 目标模式不显示所选项时详情暂停(隐藏但保留
  429. 选择身份),切回且对象恢复可见时自动恢复;引导态同样不显示。
  430. -->
  431. <div v-if="detailOpen && !introVisible && selectionVisibleInMode && anchorLayout.visible && !fixedLocationReason"
  432. class="cockpit-shell__anchor-lead" data-anchor-lead :style="anchorLeadStyle" aria-hidden="true"></div>
  433. <aside v-if="detailOpen && !introVisible && selectionVisibleInMode" ref="detailRef" class="cockpit-shell__detail"
  434. :class="{ 'is-anchored': !fixedLocationReason }"
  435. :style="detailStyle"
  436. :data-anchor-state="fixedLocationReason ? 'fixed' : anchorLayout.visible ? 'visible' : 'hidden'"
  437. :data-anchor-side="anchorLayout.side"
  438. :data-anchor-code="selectedDetailCode"
  439. :data-selection-paused="selectionVisibleInMode ? 'no' : 'yes'"
  440. aria-label="点位与设备详情">
  441. <PointDetailCard :location-reason="fixedLocationReason" />
  442. </aside>
  443. <aside v-if="eventStore.selectedEvent && uiStore.mode === 'warningResponse' && !introVisible"
  444. class="cockpit-shell__event-detail" aria-label="事件详情" @keydown.esc="eventStore.closeEvent()">
  445. <EventDetailCard />
  446. </aside>
  447. <!--
  448. 票 38(第 5 条):定位反馈浮层。原来它挂在顶部「状态详情」里,而那块**默认收起**——
  449. 点「定位」后失败提示永远看不见,维护者读作「点了没反应」。「没反应」是缺陷,
  450. 所以三种失败(不在已加载范围 / 后端判定不可上图 / 地图未就绪)必须**当场可见**:
  451. 居中底部浮层,自动收起。成功仍不给文字(地图真的会动、详情卡会开,再弹一句是噪音)。
  452. data-locate-result 断言面随它搬到这里,走查仍按该属性取数。
  453. -->
  454. <div v-if="locateFeedback" class="cockpit-shell__notice" data-locate-notice>
  455. <span
  456. class="cockpit-shell__notice-chip cockpit-shell__locate"
  457. :data-locate-result="locateFeedback.result"
  458. role="status"
  459. >
  460. {{ locateFeedback.pointCode }} · {{ locateFeedback.reasonText }}
  461. </span>
  462. <button type="button" class="cockpit-shell__notice-close" data-locate-close @click="dismissLocateFeedback">关闭</button>
  463. </div>
  464. </main>
  465. <!-- 进入引导(票 30):覆盖全屏,点击进入后飞到目标城市预设视角 -->
  466. <CockpitIntro
  467. v-if="intro.enabled && introVisible"
  468. :title="intro.title"
  469. :subtitle="intro.subtitle"
  470. :spin-seconds="intro.spinSeconds"
  471. @enter="enterSystem"
  472. />
  473. </div>
  474. </template>
  475. <script setup>
  476. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  477. import { storeToRefs } from 'pinia'
  478. import { MODES, columnsForMode, labelForPanel, modeLabel } from './domain/panelCatalog.js'
  479. import { panelView, hasPanelView } from './domain/panelViews.js'
  480. // 票 67:全局筛选(当前范围/行业)生效面的**单一措辞来源**(面板口径说明 + 管线图层那句)。
  481. import { PIPE_DISTRICT_NOTE } from './domain/filterCaliber.js'
  482. // 票 65:「预警效率」面板标题随预警/报警切换,词表复用 domain 单一词条。
  483. import { responseEfficiencyTitle } from './domain/warningResponsePanels.js'
  484. // 票 20:概览的联动映射(`link` 键 → 目标模式);外壳不自己发明这张表。
  485. import { OVERVIEW_LINK_MODES } from './domain/overviewPanels.js'
  486. import { LOCATE_RESULT } from './domain/mapLocate.js'
  487. import { resolveDetailAnchor, detailAnchorLocation } from './domain/detailAnchor.js'
  488. import { DEVICE_LAYER_KIND } from './domain/deviceLayer.js'
  489. import { ANCHOR_HIDDEN, solvePointAnchor } from './domain/pointAnchor.js'
  490. import {
  491. overallFreshness,
  492. overallFreshnessText,
  493. sectionStateOf,
  494. viewFromSectionState,
  495. } from './domain/freshnessModel.js'
  496. import { INDUSTRIES, SCOPES, industryQuery, districtQuery, industryIcon } from './domain/filterOptions.js'
  497. import { VIEW_MODES } from './domain/viewModes.js'
  498. import { useUiStore } from './stores/uiStore.js'
  499. import { useFilterStore } from './stores/filterStore.js'
  500. import { usePipeNetworkStore } from './stores/pipeNetworkStore.js'
  501. import CockpitPanel from './components/CockpitPanel.vue'
  502. import CockpitIcon from './components/CockpitIcon.vue'
  503. import MapCanvas from './components/MapCanvas.vue'
  504. import PipeEnhancement from './components/PipeEnhancement.vue'
  505. // 票 48:管线颜色图例(一张图正下方居中持久显示;色值/词条/计数全部同源 domain/pipeVisual.js)。
  506. import PipeColorLegend from './components/PipeColorLegend.vue'
  507. // 票 25:地图信息内容块(挂在本外壳的状态面板内,不另起容器)。
  508. import MapInfoPanel from './components/MapInfoPanel.vue'
  509. import PanelPlaceholder from './components/PanelPlaceholder.vue'
  510. import PanelBlocks from './components/PanelBlocks.vue'
  511. import EventListPane from './components/EventListPane.vue'
  512. import EventDetailCard from './components/EventDetailCard.vue'
  513. import WarningResponsePane from './components/WarningResponsePane.vue'
  514. import StatusTag from './components/StatusTag.vue'
  515. import CockpitIntro from './components/CockpitIntro.vue'
  516. import SectionStateTag from './components/SectionStateTag.vue'
  517. import PointDetailCard from './components/PointDetailCard.vue'
  518. import { configStatusView } from './domain/configStatus.js'
  519. import { DETAIL_LEVEL } from './domain/detailFlow.js'
  520. import { homeLabel } from './domain/viewPresets.js'
  521. import { ensureCockpitMocks } from './mocks/index.js'
  522. // 票 14:启动计划(规格 09 的主接缝)——real 与 mock 同构启动,差别只剩 registerMocks。
  523. import { STARTUP_ACTION, planCockpitStartup } from './domain/startupPlan.js'
  524. // 票 41:监测点选择的统一入口(外壳的「重新定位」也走它,不发明第二条定位路径)。
  525. import { selectMonitoringPoint } from './pointSelection.js'
  526. // 票 46:模式图层计划(唯一裁决点)+ 模式切换的选择态语义判定。
  527. import { planModeLayers, planModeSelectionTransition } from './domain/modeLayerPlan.js'
  528. import { PIPE_ENHANCEMENT_KIND, planPipeEnhancement } from './domain/pipeEnhancement.js'
  529. // 票 48:图例计数纯函数(色值/词条/计数与地图管线同源的 domain 单源)。
  530. import { pipeColorCounts } from './domain/pipeVisual.js'
  531. import { usePipeEnhancementStore } from './stores/pipeEnhancementStore.js'
  532. import { useEnhancementDevicesStore } from './stores/enhancementDevicesStore.js'
  533. import { planEnhancementDevices } from './domain/enhancementDevices.js'
  534. import { fetchDeviceFacilityRelations, relationIndex } from './services/deviceFacilityRelations.js'
  535. import { useDetailStore } from './stores/detailStore.js'
  536. import { useDeviceLayerStore } from './stores/deviceLayerStore.js'
  537. import { useInfrastructureStore } from './stores/infrastructureStore.js'
  538. import { useMonitoringStore } from './stores/monitoringStore.js'
  539. import { useEventStore } from './stores/eventStore.js'
  540. // 票 20:总体概览模式的四块面板(态势总览由另外三块合成,见其 store 的说明)。
  541. import { useOverviewStore } from './stores/overviewStore.js'
  542. import { useRuntimeStore } from './stores/runtimeStore.js'
  543. import { useMapStore } from './stores/mapStore.js'
  544. import { MAP_OBJECT_KIND, MAP_SUMMARY_PHASE } from './domain/mapSummary.js'
  545. import { getCockpitConfigState, loadCockpitConfig } from './services/cockpitConfig.js'
  546. import {
  547. atHome,
  548. getAreaLimits,
  549. getAreaState,
  550. getFogState,
  551. getHomeView,
  552. getMapCanvasRect,
  553. getMapMarkerAnchor,
  554. hasMapMarker,
  555. onMapFrame,
  556. onCameraStateChange,
  557. publishPointSelectionHandle,
  558. publishDeviceSelectionHandle,
  559. setPanelSelectionLevel,
  560. getIntroConfig,
  561. // 票 25:底图措辞与数据来源署名(Cesium 署名已移出画面,改由状态面板承担)。
  562. getBasemapLabel,
  563. getDataSourceCredits,
  564. goHome,
  565. resetToIntroView,
  566. setAreaState,
  567. setGlobeSpin,
  568. setViewMode,
  569. } from './services/mapService.js'
  570. import { AREA_STATE, GLOBAL_IDLE_MS, areaBarActions, shouldReturnToIntro } from './domain/areaLimits.js'
  571. // 视觉 token 的唯一来源(票 06)。挂在本组件根元素上,靠继承作用于驾驶舱子树。
  572. import './styles/tokens.css'
  573. // 票 17(批次 4):字体面按授权门禁注入 —— 门禁不通过的字体不生成 @font-face,
  574. // 于是「无证据字体不可被解析为运行时字体」由结构保证,而非靠约定(规格 11)。
  575. import { fontStatus } from './domain/runtimeAssets.js'
  576. import { installFontFaces } from './domain/fontFaces.js'
  577. // 票 17 第七轮:模式按钮选中态底板 / 选中光晕经语义 key 解析成 CSS 变量注入,
  578. // 组件样式里不出现物理路径(规格 09 的 manifest 唯一入口纪律)。
  579. import { iconFor } from './domain/icons.js'
  580. const uiStore = useUiStore()
  581. const filterStore = useFilterStore()
  582. // 票 24:设施点位与管线是**独立于地图摘要**的一条读取路径(不同业务对象、不同口径),
  583. // 单独一个 store 持有,避免两种对象在同一个 store 里互相污染统计。
  584. const pipeNetworkStore = usePipeNetworkStore()
  585. const detailStore = useDetailStore()
  586. const mapStore = useMapStore()
  587. const infrastructureStore = useInfrastructureStore()
  588. const monitoringStore = useMonitoringStore()
  589. const overviewStore = useOverviewStore()
  590. const eventStore = useEventStore()
  591. const runtimeStore = useRuntimeStore()
  592. const statusPanelExpanded = ref(false)
  593. const { mode, expandedPanelIds } = storeToRefs(uiStore)
  594. const { scope, industry, deviceType, viewMode, pointStatus, areaState } = storeToRefs(filterStore)
  595. /**
  596. * 批次 1:行业筛选按钮带上图标(`all` 无图标,渲染时不画)。
  597. * 单独派生一份而不是就地改 `INDUSTRIES` —— 筛选契约本身不带视觉信息。
  598. */
  599. const industryChips = computed(() => INDUSTRIES.map((item) => ({ ...item, icon: industryIcon(item.key) })))
  600. /** 与筛选按钮文字配套的图标边长(走查反馈偏小,由 11 上调)。 */
  601. const industryIconSize = 'calc(14 * var(--u, 1px))'
  602. // ---- 四个已接数据的模式(票 17 基础设施 / 票 18 运行监测 / 票 19 预警处置 / 票 20 总体概览)----
  603. // 16 块面板现已全部接上真实视图;`PanelPlaceholder` 仍是未注册 panelId 的兜底。
  604. const { sections: infrastructureSections } = storeToRefs(infrastructureStore)
  605. const { sections: monitoringSections, badges: monitoringBadges } = storeToRefs(monitoringStore)
  606. // 票 20:概览四块的分区(态势总览那块由 `overviewStore.situationSection()` 合成,不在这个对象里)。
  607. const { sections: overviewSections } = storeToRefs(overviewStore)
  608. /**
  609. * 启动已就绪(配置落地且按启动计划执行完首轮取数)——筛选跟随只在这之后生效。
  610. * 票 14:原 `mockReady` 只在 mock 分支置位(real 不取数);同构启动后两种数据源
  611. * 都会置位,改名以如实表达语义。
  612. */
  613. const lifecycleReady = ref(false)
  614. /**
  615. * 面板外框标题(票 65):预警处置「预警效率」面板的标题随预警/报警切换一起换,
  616. * 词表复用 domain 的 `responseEfficiencyTitle`(与面板内部标题同一份,不新增第二张)。
  617. * 其余面板标题仍是静态的 `panelCatalog.js` 中文名。
  618. */
  619. function panelTitle(panelId) {
  620. if (panelId === 'responseEfficiency') return responseEfficiencyTitle(eventStore.mode)
  621. return labelForPanel(panelId)
  622. }
  623. /** 该 panelId 的分区(各 store 各查各的;都没接就回 null)。 */
  624. function sectionOf(panelId) {
  625. // 票 20:态势总览**不自己取数**,它的分区由另外三块合成(见 overviewStore 的说明)。
  626. if (panelId === 'situationSummary') return overviewStore.situationSection()
  627. if (Object.prototype.hasOwnProperty.call(overviewSections.value, panelId)) return overviewSections.value[panelId]
  628. if (Object.prototype.hasOwnProperty.call(infrastructureSections.value, panelId)) return infrastructureSections.value[panelId]
  629. if (Object.prototype.hasOwnProperty.call(monitoringSections.value, panelId)) return monitoringSections.value[panelId]
  630. // 票 19:预警处置四块共享同一个 mode,并各有独立分区状态。
  631. if (['currentEvents', 'historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)) return eventStore.sections[panelId]
  632. return null
  633. }
  634. /** 还没取过数的分区(`sectionOf` 都不认识这个 panelId 时的兜底;正常情况下 store 自带 pending)。 */
  635. function pendingSection() {
  636. return { state: 'empty', reason: '', mode: '', data: null, missing: [], freshness: '', retained: false, loaded: false }
  637. }
  638. /**
  639. * panelId → 算好的视图计划(分发归 `domain/panelViews.js` 的注册表,外壳只做参数搬运)。
  640. *
  641. * **已接数据的面板一定有视图**(哪怕还没取数):domain 的视图计划对 `data: null` 仍返回
  642. * 结构完整的对象,其 `placeholder` 是「正在取数…」。所以「已实现」与「还没实现」由
  643. * `hasPanelView(panelId)` 分辨(那才决定走组件还是走占位),**不由视图在不在分辨** ——
  644. * 早先把两者混起来时,一个已接数据但还在加载的面板会被画成「面板占位 · panelId」,
  645. * 那是在说"这块还没做",与实际不符。
  646. *
  647. * 台账要额外喂一份徽标(票 18 的 N+1),所以只有它多带第三个参数 —— 这是本票唯一的
  648. * "某个面板需要额外输入"的地方,写在壳里比塞进注册表签名更清楚。
  649. */
  650. function panelViewOf(panelId) {
  651. if (!hasPanelView(panelId)) return null
  652. const section = sectionOf(panelId) || pendingSection()
  653. /** 台账要额外喂一份徽标(票 18 的 N+1);票 14:监测分布的饼图要喂全局未筛分布与当前选中。 */
  654. let extra
  655. if (panelId === 'monitoringPointLedger') extra = monitoringBadges.value
  656. else if (panelId === 'monitoringDistribution') {
  657. extra = { statusDistribution: mapStore.statusDistribution, selectedStatuses: pointStatus.value }
  658. }
  659. // 票 64:运行信息面板要当前**片区**键,用来给指标列表算页码归属(换片区 ⇒ 页码回第 1 页,
  660. // 周期刷新 ⇒ 页码保持)。只传标量、不传块对象,与票 43 第四轮「别拿对象当键」同一条纪律。
  661. else if (panelId === 'operation') extra = { scope: scope.value }
  662. // 票 62 追加需求:运行概况的**设备台账**数据在 `monitoringOverview` 分区(`devices[]`,
  663. // 与那边 `counts.equipmentCount` 同源),而本面板自己的分区是 `operationOverview`
  664. // (`/monitoring/situation`:趋势/指标状态/异常口径)。跨分区取数只在这一处搬运 ——
  665. // 与上面「态势总览横跨三块」同一立场,注册表签名保持 `(panelId, data, section, extra)`。
  666. else if (panelId === 'operationOverview') {
  667. const overview = monitoringSections.value.monitoringOverview
  668. extra = { devices: (overview && overview.data && overview.data.devices) || [] }
  669. }
  670. // 票 20:态势总览要**三份领域对象**(它横跨基础设施/运行监测/预警设置三块),
  671. // 与基础设施面板要「设施底数 + 行业构成 + 区域分布」一样,都是"这一块的数据来自哪几处"
  672. // 的搬运 —— 写在壳里,注册表签名保持 `(panelId, data, section, extra)` 不变。
  673. if (panelId === 'situationSummary') return panelView(panelId, overviewStore.situationData(), section, extra)
  674. if (panelId === 'infrastructureSummary') return panelView(panelId, overviewStore.infrastructureData(), section, extra)
  675. return panelView(panelId, section.data, section, extra)
  676. }
  677. /**
  678. * 面板标题右侧的提要:有数据的写一句口径摘要;**已接数据但还没取数**写「正在取数」;
  679. * 没实现的写「面板占位」—— 后两者必须分开,否则"正在加载"会被读成"这块还没做"。
  680. */
  681. function panelMeta(panelId) {
  682. // 票 13:当前预警面板的提要由「事件 + 切换」视图给(条数/状态来自事件分区)。
  683. if (panelId === 'currentEvents') {
  684. const view = eventStore.view
  685. const section = eventStore.sections.currentEvents
  686. if (!section || section.loaded !== true) return '正在取数'
  687. if (!view.hasContent) return '暂无事件'
  688. // 票 61:条数读服务端 total(不由当前页反推);多页时带当前页读数。
  689. const total = view.pagination ? view.pagination.total : view.rows.length
  690. return view.pagination && view.pagination.pageCount > 1
  691. ? `${total} 条事件 · 第 ${view.pagination.page}/${view.pagination.pageCount} 页`
  692. : `${total} 条事件`
  693. }
  694. if (!hasPanelView(panelId)) return '面板占位'
  695. const section = sectionOf(panelId)
  696. if (!section || section.loaded !== true) return '正在取数'
  697. const view = panelViewOf(panelId)
  698. if (!view || !view.hasContent) return '暂无数据'
  699. return `${view.blocks.length} 组读数`
  700. }
  701. /** 定位反馈:只有失败情形值得给一行字(成功时地图与详情卡自己会说话)。 */
  702. const locateFeedback = computed(() => {
  703. const current = infrastructureStore.locate
  704. if (!current || !current.result || current.result === LOCATE_RESULT.OK) return null
  705. return current
  706. })
  707. /**
  708. * 票 38:反馈浮层自动收起。失败提示要**当场看得见**(不再藏在折叠面板里),
  709. * 但也不能常驻——值班人员接着点下一个点位时,上一条的说明早该走了。
  710. * 定时器随结果变化重建:同一点位重取(result 相同但 pointCode 变)也算一次新反馈。
  711. * 手动「关闭」与自动到点走同一个 `clearLocate`(store 是唯一持有者,壳不自己抹状态)。
  712. */
  713. let locateDismissTimer = null
  714. watch(locateFeedback, (current) => {
  715. if (locateDismissTimer) {
  716. window.clearTimeout(locateDismissTimer)
  717. locateDismissTimer = null
  718. }
  719. if (!current) return
  720. locateDismissTimer = window.setTimeout(() => {
  721. locateDismissTimer = null
  722. infrastructureStore.clearLocate()
  723. }, 8000)
  724. })
  725. function dismissLocateFeedback() {
  726. if (locateDismissTimer) {
  727. window.clearTimeout(locateDismissTimer)
  728. locateDismissTimer = null
  729. }
  730. infrastructureStore.clearLocate()
  731. }
  732. /** 点面板里的可定位对象 → 地图定位(接缝在 store,本组件只管转发)。 */function locateOnMap(pointCode) {
  733. return infrastructureStore.locatePoint(pointCode)
  734. }
  735. /**
  736. * 票 18:点台账卡片 → 打开与地图**相同**的两级详情。
  737. *
  738. * 刻意不移动相机:地图上点标记也不动相机(ADR 0008 的既有语义),
  739. * 台账若顺手把镜头飞走,同一份详情会有两种手感(Q4 裁定的复用方式)。
  740. */
  741. function openLedgerCard(pointCode) {
  742. return monitoringStore.openLedgerPoint(pointCode)
  743. }
  744. /**
  745. * 票 14:监测分布面板的 `filter-status` 出口已随「面板自带筛选入口」一并移除——
  746. * 状态筛选的唯一入口是地图顶部状态栏(`MapCanvas` 直接写 filterStore),
  747. * 全局条件只有一个可操作面(决策 02 的 Q19)。
  748. */
  749. /** 票 18:台账卡片进视口 → 补拉该点位的设备徽标(Q14 裁定:进视口才拉、逐点位、失败不牵连其它卡片)。
  750. *
  751. * 触发点在 `PanelBlocks.vue` 的 `IntersectionObserver`(组件只回答"这张卡露出来了"),
  752. * 取数与记账在 store —— 组件不认识接口,也不自己发请求。
  753. */
  754. function loadLedgerBadge(pointCode) {
  755. return monitoringStore.loadLedgerBadge(pointCode)
  756. }
  757. /**
  758. * 票 20 立的联动接缝(票 37 复用):渲染层只回答「哪一项被点了」(`{ kind, key }`),
  759. * **它不认识 panelId、也不认识业务语义**;把 `kind` 翻成动作收在这一处,
  760. * 与 `panelViews.js` 把 panelId 翻成视图计划是同一个立场。
  761. *
  762. * 票 37 增 `deviceType` 一种:点监测概览的类型分布行 → 写全局设备类型筛选
  763. * (toggle 语义在 filterStore:再点同一行取消)。**没有"地图定位"这一种**:
  764. * 概览/概览的端点都不含点位编码或坐标(见 `domain/overviewPanels.js` 文件头第 ① 条),
  765. * `link` 落到预警处置模式后,那条链路(事件带设备编码 → 票 13/14 跳点位)才是真实存在的。
  766. * @param {{kind: string, key: string}} action
  767. */
  768. function handlePanelAction(action) {
  769. if (!action) return false
  770. if (action.kind === 'link') return overviewStore.linkToMode(overviewLinkMode(action.key))
  771. if (action.kind === 'industry') return overviewStore.linkToIndustry(action.key)
  772. if (action.kind === 'deviceType') {
  773. filterStore.toggleDeviceType(action.key)
  774. return true
  775. }
  776. return false
  777. }
  778. /** `link` 键 → 目标一级模式(映射表在 domain,外壳不发明语义)。 */
  779. function overviewLinkMode(linkKey) {
  780. const found = (OVERVIEW_LINK_MODES || {})[linkKey]
  781. return found || ''
  782. }
  783. /** 票 21:视图条件的两个选项(决策地图票 05:全局视图条件,与范围/行业同级)。 */
  784. const VIEW_MODE_OPTIONS = [
  785. { key: VIEW_MODES.GROUND, label: '地面视图', hint: '常规俯视;管线隐藏、地形与模型不透明' },
  786. { key: VIEW_MODES.PIPELINE, label: '管线视图', hint: '压低弹簧臂、放宽俯仰;地形与模型半透明以露出管线' },
  787. ]
  788. /**
  789. * 切换视图:状态先进 store(全局条件,切一级模式不重置),再下发给地图服务。
  790. * 引擎没就绪时服务返回 false,稍后重试 —— 与「归位」同一套兜底。
  791. */
  792. function switchViewMode(next) {
  793. if (!VIEW_MODE_OPTIONS.some((item) => item.key === next)) return
  794. filterStore.setViewMode(next)
  795. // 票 21:倾角/透明度可由 map.viewMode 覆盖(缺省用 domain/viewModes.js 的默认)。
  796. const mapConfig =
  797. configState.value && configState.value.config && configState.value.config.map ? configState.value.config.map : null
  798. const overrides = (mapConfig && mapConfig.viewMode) || {}
  799. let tries = 0
  800. const apply = () => {
  801. tries += 1
  802. if (setViewMode(next, overrides) || tries >= 40) {
  803. // 票 37:视图切换会换 range 区间(管线 [400, 6000])并与区域限位取交集,
  804. // 所以顶栏那条「区内限位 · 最远 N m」要跟着刷新 —— 它是人工核对三处交集的读数。
  805. areaLimits.value = getAreaLimits()
  806. return
  807. }
  808. window.setTimeout(apply, 250)
  809. }
  810. apply()
  811. }
  812. const { sections, selection } = storeToRefs(detailStore)
  813. // ---- 票 42:关联监测设备临时图层 ----
  814. //
  815. // 本层是**合成点**:它读 mapStore 的当前快照 `deviceCodes`(总数依据)与 detailStore 的点位详情
  816. // `devices[]`(状态与设备级坐标),按 `domain/deviceLayer.js` 判定,再由地图服务画到独立图层。
  817. // 外壳负责三类触发(缺一条都会让图层停在过期状态):
  818. // ① **选择变化**:换点/关闭 → 立即重算(关闭时无点位级选择 ⇒ 撤下图层);
  819. // ② **详情到达**:设备级坐标与状态来自详情,它回来之前设备只能是「待确认」;
  820. // ③ **快照变化**:刷新/筛选重载/快照失效 → **先撤下旧图层**再等新的(规格明写,不许留旧数据)。
  821. const deviceLayerStore = useDeviceLayerStore()
  822. const { rows: deviceRows, counts: deviceCounts, legend: deviceLegend, notice: deviceNotice } = storeToRefs(deviceLayerStore)
  823. /** 最近一次「已撤下旧图层」对应的快照标记(同一个快照不重复撤,避免翻页时把图层打没)。 */
  824. let deviceSnapshotSeen = ''
  825. /**
  826. * 票 42(规格 14:刷新核对链)——外壳持有的**核对状态**:
  827. * · `snapshotPending`:刷新/失效发起后、核对收口前,设备图层挂起 + 顶栏「待核对」;
  828. * · `snapshotAbsentNote`:完整加载仍找不到所选点位 → 详情已关闭,顶栏说明原因。
  829. * 挂起与收口由下方三个监听驱动(发起 / 失效相位 / settled)。
  830. */
  831. const snapshotPending = ref(false)
  832. const snapshotAbsentNote = ref('')
  833. /**
  834. * 票 14:设备级筛选(行业/范围/类型)是否生效——生效时 map-snapshot 的 `deviceCodes`
  835. * 是**命中设备子集**,详情独有按「未命中当前筛选」归类(另有 N 台),而非快照不一致。
  836. * 状态筛选不算:它作用在对象状态上,不裁剪点位的设备清单。
  837. */
  838. const deviceFilterActive = computed(
  839. () => industryQuery(industry.value) !== '' || districtQuery(scope.value) !== '' || String(deviceType.value || '').trim() !== '',
  840. )
  841. function syncDeviceLayer(options = {}) {
  842. // 票 46:`entered` 跟随引导态 —— 进入前关联设备图层整体不呈现(规格 18 的引导列)。
  843. // 票 45:重叠展开阈值来自 `map.overlap.maxFan`,未配置时用规格默认 10。
  844. const mapConfig = configState.value && configState.value.config && configState.value.config.map
  845. const maxFan = options.maxFan || (mapConfig && mapConfig.overlap && mapConfig.overlap.maxFan) || 10
  846. return deviceLayerStore.syncForSelection({
  847. ...options,
  848. mode: mode.value,
  849. entered: !introVisible.value,
  850. parentVisible: !selection.value.pointCode || [...modeLayerPlan.value.markers, ...modeLayerPlan.value.blocked]
  851. .some((item) => item.code === selection.value.pointCode),
  852. maxFan,
  853. filterActive: deviceFilterActive.value,
  854. })
  855. }
  856. /**
  857. * 票 46:**异常设备集**(规格 18 的输入)。
  858. *
  859. * 来源是 `/monitoring/situation` 的 `abnormalEquipment[]`(运行监测「运行概况」面板的数据,
  860. * `monitoringStore.sections.operationOverview`)—— 那是**设备状态事实**,不是监测点聚合状态。
  861. * 它与 `map-snapshot` 跨端点:数据没取到时给 `null`(计划层会输出降级说明,不猜异常点)。
  862. */
  863. const modeAbnormalDevices = computed(() => {
  864. const section = monitoringSections.value.operationOverview
  865. const data = section && section.data
  866. return data && Array.isArray(data.abnormalEquipment) ? data.abnormalEquipment : null
  867. })
  868. // ---- 进入引导与视角预设(票 30)——声明上移至此 ----
  869. // 原位置在下方「进入引导与视角预设」分节;因管线增强 watch(票 24/47)在 setup 期
  870. // 急切读取 `modeLayerPlan`(其 computed 引用 `introVisible`),声明晚于该 watch 会触发
  871. // TDZ(ReferenceError: Cannot access 'introVisible' before initialization,整壳挂掉)。
  872. // 声明只依赖 `getIntroConfig()`(顶部 import),上移安全;分节的其余内容仍在原处。
  873. const intro = ref(getIntroConfig())
  874. const introVisible = ref(intro.value.enabled)
  875. /**
  876. * 票 46:**唯一**的模式图层计划(规格 18:引擎和组件只消费计划,不做第二次过滤)。
  877. *
  878. * 在外壳算一次(外壳是唯一同时握有 mapStore 对象、设备行、异常设备与选择态的层),
  879. * 地图组件、图例、详情暂停都只读这一份:
  880. * · `markers/legend` → MapCanvas(图例与标记同源,只统计本模式可见的标记);
  881. * · `selection.visible` → 详情浮层的**暂停/恢复**(目标模式不显示所选项时隐藏但保留选择);
  882. * · `degradedNotes` → 「设施/管线/异常设备数据未就绪」的如实说明。
  883. */
  884. const pipeEnhancementStore = usePipeEnhancementStore()
  885. const enhancementDevicesStore = useEnhancementDevicesStore()
  886. const { selected: pipeEnhancementSelection, radiusM: pipeEnhancementRadius } = storeToRefs(pipeEnhancementStore)
  887. const modeLayerPlan = computed(() => planModeLayers({
  888. mode: mode.value,
  889. objects: mapStore.summary.objects,
  890. abnormalDevices: modeAbnormalDevices.value,
  891. // 票 24:设施点位与管线接真实图层。取不到时由 store 收成失败分区,
  892. // 计划如实降级为「未就绪」——不再传 `null` 假装"还没接线"。
  893. // 过滤只在这一层做(`pipeNetworkStore.filtered`),引擎与组件都不做第二次过滤。
  894. facilities: pipeNetworkStore.facilities,
  895. pipelines: pipeNetworkStore.pipelines,
  896. associatedDevices: deviceRows.value,
  897. selection: {
  898. level: selection.value.level,
  899. pointCode: selection.value.pointCode,
  900. deviceCode: selection.value.deviceCode,
  901. },
  902. entered: !introVisible.value,
  903. }))
  904. /**
  905. * 设备→设施真实关联索引(维护者 2026-10-22 批准的后端契约)。
  906. *
  907. * 后端 `GET /api/cockpit/v1/device-facility-relations` 只按真实主键解析出 `deviceCode` 的行;
  908. * 当前库数据下通常为空(关联表存的是设备 id、管点 network_id 悬空、equipment_id 查不到),
  909. * 因此这里拿到空 Map 属**正常降级**,界面继续标「邻近设备 · 示意安装」,不伪报真实绑定。
  910. */
  911. const facilityRelations = ref(new Map())
  912. const facilityRelationsState = ref({ state: 'idle', loaded: false })
  913. async function loadFacilityRelations() {
  914. try {
  915. const section = await fetchDeviceFacilityRelations()
  916. facilityRelations.value = relationIndex(section)
  917. facilityRelationsState.value = { state: section.state, loaded: true }
  918. } catch {
  919. facilityRelations.value = new Map()
  920. facilityRelationsState.value = { state: 'failed', loaded: true }
  921. }
  922. }
  923. const pipeEnhancementPlan = computed(() => {
  924. const plan = planPipeEnhancement({
  925. selected: pipeEnhancementSelection.value,
  926. radiusM: pipeEnhancementRadius.value,
  927. visiblePipes: modeLayerPlan.value.pipelines,
  928. visibleComponents: modeLayerPlan.value.facilities,
  929. })
  930. return { ...plan, ...planEnhancementDevices(plan, enhancementDevicesStore.source.rows, {
  931. mode: mode.value, abnormalDevices: modeAbnormalDevices.value, facilityRelations: facilityRelations.value,
  932. }),
  933. deviceCoverage: enhancementDevicesStore.source,
  934. relationCoverage: facilityRelationsState.value }
  935. })
  936. watch(pipeEnhancementSelection, () => enhancementDevicesStore.closeDetail())
  937. // 首次打开详情视图时取一次真实关联(只读、免认证);失败按空索引降级,绝不阻塞场景。
  938. watch(() => pipeEnhancementPlan.value.visible, (visible) => {
  939. if (visible && !facilityRelationsState.value.loaded) void loadFacilityRelations()
  940. })
  941. watch(() => pipeEnhancementPlan.value.deviceList, (list) => enhancementDevicesStore.reconcile(list))
  942. watch(() => mapStore.refreshing, (refreshing) => {
  943. if (refreshing) enhancementDevicesStore.invalidate()
  944. else if (pipeEnhancementPlan.value.visible) void enhancementDevicesStore.load(mapStore.summary)
  945. }, { flush: 'sync' })
  946. watch(() => [pipeEnhancementSelection.value, modeLayerPlan.value.pipelines, modeLayerPlan.value.facilities,
  947. mapStore.summary.batchUpdatedAt, mapStore.summary.phase, mapStore.summary.objects], () => {
  948. if (mapStore.refreshing) return
  949. if (pipeEnhancementPlan.value.visible) void enhancementDevicesStore.load(mapStore.summary)
  950. else enhancementDevicesStore.clear()
  951. })
  952. /**
  953. * 票 48:管线颜色图例的计数(`pipeColorCounts()` 的视图入口)。
  954. *
  955. * 读 `pipeNetworkStore.layer.data` 的**全量**图层(不读 `filtered`):
  956. * 图例回答的是「图上这类颜色是什么」,计数不随行业筛选变化——与顶部状态图例
  957. * 「计数=全局未筛全集」的既定立场(决策 02)同一条纪律。图层没取数/取数失败时
  958. * `data` 为 null → `available:false` → 图例各行不画计数(不补 0)。
  959. */
  960. const pipeColorCountsView = computed(() => pipeColorCounts(pipeNetworkStore.layer.data))
  961. function selectPipeEnhancement(hit) {
  962. const kind = hit && hit.objectKind === 'component'
  963. ? PIPE_ENHANCEMENT_KIND.COMPONENT
  964. : hit && hit.objectKind === 'pipe'
  965. ? PIPE_ENHANCEMENT_KIND.PIPE
  966. : ''
  967. if (!kind) return
  968. pipeEnhancementStore.select(kind, hit.code)
  969. }
  970. function closePipeEnhancement() {
  971. pipeEnhancementStore.clear()
  972. enhancementDevicesStore.clear()
  973. }
  974. const selectionVisibleInMode = computed(() => modeLayerPlan.value.selection.visible === true)
  975. /**
  976. * 快照变化 → 先撤下旧设备图层(规格:刷新或快照失效时先撤下,免得旧快照的设备被误读成新范围的)。
  977. * **同一个标记不重复撤**:翻页时标记不变,此时若也撤,会把正在看的设备层打没
  978. * —— 那正是规格禁止的「同一快照翻页造成闪烁」。
  979. *
  980. * 用 `batchUpdatedAt` 而不是 `summary.snapshot` 作判据:两者是同一份合并态里同批更新的
  981. * 等价标记(见 `domain/mapSummary.js` 的 `mergeMapPage`),但 `snapshot` 那个**字段名**
  982. * 会被 t08 的 H1 守卫命中 —— 该守卫查的是「界面不得出现分页/快照的**接口参数名**」
  983. * (票 08 验收第 2 条)。我不放宽守卫,改用不含该字样的等价字段。
  984. */
  985. function syncDeviceLayerForSnapshot(batchUpdatedAt) {
  986. const current = batchUpdatedAt || ''
  987. if (current && current === deviceSnapshotSeen) return
  988. deviceSnapshotSeen = current
  989. deviceLayerStore.invalidateForSnapshot({ filterActive: deviceFilterActive.value })
  990. syncDeviceLayer({ force: true })
  991. }
  992. /**
  993. * 票 42(规格 14:刷新核对链):快照链 settled 后对「挂起中的所选点位」做一次核对。
  994. *
  995. * 三种收口,全部是规格 14:86 的原句:
  996. * · **找到**(含被护栏截掉的前部页——用装载器的全页索引,不用合并态当证据):
  997. * 强制重取点位摘要(保留选择层级),设备图层经既有监听自行恢复;
  998. * · **确认无地图位置**:固定详情由既有 `fixedLocationReason` 呈现,无需额外处理;
  999. * · **完整加载仍找不到**:关闭详情并在顶栏说明原因。
  1000. * 查找失败(failed 相位)**保持待核对**:已取数据与顶栏关闭入口都在,允许再次刷新重试。
  1001. */
  1002. function reconcileSnapshotSelection() {
  1003. const pointCode = selection.value.pointCode
  1004. if (!pointCode || selection.value.level === 'none') {
  1005. snapshotPending.value = false
  1006. return
  1007. }
  1008. const phase = mapStore.summary.phase
  1009. // 失败与失效都是「查找失败」:保持待核对、已取数据与顶栏关闭入口,允许再次刷新重试
  1010. // (规格 14:86 原句)——不得对已失效的快照做「找到即重载」。
  1011. if (phase === MAP_SUMMARY_PHASE.FAILED || phase === MAP_SUMMARY_PHASE.EXPIRED) return
  1012. if (mapStore.hasSnapshotObject('monitoring-point', pointCode)) {
  1013. snapshotPending.value = false
  1014. void detailStore.reloadPointDetail(pointCode)
  1015. return
  1016. }
  1017. if (phase === MAP_SUMMARY_PHASE.COMPLETE || phase === MAP_SUMMARY_PHASE.EMPTY) {
  1018. snapshotPending.value = false
  1019. snapshotAbsentNote.value = `所选点位 ${pointCode} 已不在当前快照范围,详情已关闭`
  1020. detailStore.closeDetail()
  1021. }
  1022. }
  1023. /**
  1024. * 票 46:模式切换的**选择态语义**(规格 18 / 决策票 13):
  1025. * · 总览 ↔ 任一分项:保留选择身份;目标模式不显示所选项时**暂停**(详情浮层隐藏、设备图层撤下),
  1026. * 切回总览且对象仍在当前范围时恢复 —— 恢复就是"计划重新判定为可见",不需要额外状态;
  1027. * · 分项 ↔ 分项:清空选择(走 `detailStore.closeDetail()`,唯一的关闭入口,不另造状态)。
  1028. * 判定在 `domain/modeLayerPlan.js` 的纯函数里,本壳只执行它的结论。
  1029. */
  1030. function applyModeSelectionTransition(fromMode, toMode) {
  1031. const verdict = planModeSelectionTransition({
  1032. fromMode,
  1033. toMode,
  1034. selection: {
  1035. level: selection.value.level,
  1036. pointCode: selection.value.pointCode,
  1037. deviceCode: selection.value.deviceCode,
  1038. },
  1039. })
  1040. if (verdict.action !== 'clear') return verdict
  1041. // 分项→分项:清空。closeDetail 是唯一的关闭入口(不动相机、不换快照)。
  1042. detailStore.closeDetail()
  1043. return verdict
  1044. }
  1045. /** 引导态/进入系统后的联动:设备图层与锚定都要按 `entered` 重算一次。 */
  1046. function syncLayersForEntered(nextEntered) {
  1047. if (nextEntered) {
  1048. syncDeviceLayer({ force: true })
  1049. refreshAnchor({ force: true })
  1050. } else {
  1051. // 回到引导态(闲置回引导页):撤下设备图层(计划层已把一切输出置空)。
  1052. syncDeviceLayer({ force: true })
  1053. }
  1054. }
  1055. const stopDeviceWatches = []
  1056. /** 票 10:两级详情是否打开(浮层只在有选择时渲染;关掉后地图与面板保持原样)。 */
  1057. const detailOpen = computed(() => selection.value.level !== DETAIL_LEVEL.NONE)
  1058. // ---- 票 41:详情面板锚定跟随监测点标记(决策票 04 / 规格票 12 的修订节)----
  1059. //
  1060. // 三条口径直接来自规格,不要在本层自行发明:
  1061. // 1. **始终贴可见标记**:面板只避开画布边缘,**不再避让左右业务栏/图例/底部摘要**(R-1),
  1062. // 被详情窗遮住是允许的、消失不是(R-4);
  1063. // 2. **只在窗口完全离开画面时隐藏**(R-4),且**允许被画布裁切**(R-2)——
  1064. // 所以 `no-space` / `icon-covered` 两种暂隐原因在本层**不再存在**(规格要求移除或标废弃);
  1065. // 3. **层级低于四个详情窗**(R-3):`z-index` 取 1,压在 `--z-columns` 之下。
  1066. //
  1067. // 投影与落位都是**只读**的:地图服务给「标记在不在、投到哪儿」,本组件只摆 DOM,
  1068. // 不持有 Cesium 实体,也不把帧坐标塞进任何业务 store。
  1069. const detailRef = ref(null)
  1070. const anchorLayout = ref({ visible: false, reason: 'canvas-missing', rect: null, line: null, side: '' })
  1071. /** 面板坐标基准:`main` 的窗口矩形(投影给的是窗口坐标,两者相减才是 DOM 局部坐标)。 */
  1072. const anchorOrigin = ref({ left: 0, top: 0 })
  1073. /** 上一帧写下去的落位:与本次相同就**不再写 DOM**(规格:镜头稳定且布局未变时停止重算)。 */
  1074. let lastAnchorKey = ''
  1075. /** 摘要锚向监测点;设备详情锚向所选设备;业务归属编码保持不变。 */
  1076. const detailTarget = computed(() => resolveDetailAnchor({
  1077. selection: selection.value,
  1078. objects: mapStore.summary.objects,
  1079. devices: deviceRows.value,
  1080. }))
  1081. /** 详情卡上要显示的对象编码(顶栏暂隐入口读它)。 */
  1082. const selectedDetailCode = computed(() => {
  1083. const target = detailTarget.value
  1084. return target ? target.code : ''
  1085. })
  1086. /**
  1087. * 「无地图位置」的固定详情(票 04 的特例)。
  1088. *
  1089. * 判据由 `detailAnchorLocation()` 转交坐标准入领域函数,按当前点位或设备读取——
  1090. * 外壳不自己调 `isRenderable` / `blockReason`:界面自己判坐标正是票 07 的 I3 守卫禁止的事。
  1091. *
  1092. * 三种情形分得开:对象**不在已加载快照里**(分页没翻到 / 引擎未就绪)**不算**无地图位置,
  1093. * 那是"暂时无法锚定",走暂隐并保留选择;只有后端明确判不可上图才转固定详情。
  1094. */
  1095. const fixedLocationReason = computed(() => {
  1096. const target = detailTarget.value
  1097. if (!target) return ''
  1098. const verdict = detailAnchorLocation(target)
  1099. return verdict.present && !verdict.renderable ? verdict.reasonText : ''
  1100. })
  1101. const hiddenAnchorText = computed(() => ANCHOR_HIDDEN[anchorLayout.value.reason] || '点位标记当前不可见')
  1102. /** 仅**标记仍有效**时才给重新定位入口(不对已消失的标记发无效定位)。 */
  1103. const canRelocate = computed(() => {
  1104. if (introVisible.value || !detailTarget.value || fixedLocationReason.value) return false
  1105. return hasMapMarker(detailTarget.value.kind, detailTarget.value.code, detailTarget.value.pointCode)
  1106. })
  1107. /**
  1108. * 求当前锚点的落位。
  1109. * 返回 `{ canvas, layout }`:`canvas` 只作内部判定输入,`layout` 才是写进视图的对象
  1110. * (暂隐与可见**同形**,模板才能只读一组字段,不会出现"有时读得到 reason、有时读不到")。
  1111. */
  1112. function measureAnchor() {
  1113. const canvas = getMapCanvasRect()
  1114. if (!canvas) return { canvas: null, layout: { visible: false, reason: 'canvas-missing', rect: null, line: null, side: '' } }
  1115. const hidden = (reason) => ({ canvas, layout: { visible: false, reason, rect: null, line: null, side: '' } })
  1116. const target = detailTarget.value
  1117. if (!target) return hidden('marker-missing')
  1118. if (!hasMapMarker(target.kind, target.code, target.pointCode)) return hidden('marker-missing')
  1119. const anchor = getMapMarkerAnchor(target.kind, target.code, target.pointCode)
  1120. // 投影拿不到(相机背面 / 地平线后 / 投影无效)与标记在画布外,都归"完全离开画面"。
  1121. if (!anchor) return hidden('marker-offscreen')
  1122. // 高度取 `scrollHeight`(内容自然高度,**不受已写入的显式高度影响**):
  1123. // 用它做判据不会出现「设了高度→量到高度→又设回 auto」的来回抖动。
  1124. const natural = detailRef.value ? detailRef.value.scrollHeight : 0
  1125. const height = Number.isFinite(natural) && natural > 0 ? natural : 0
  1126. return { canvas, layout: solvePointAnchor({ anchor, canvas, height, iconSize: anchor.iconSize || 18, gap: anchor.panelGap || 12 }) }
  1127. }
  1128. /**
  1129. * 重算锚定落位。
  1130. *
  1131. * `options.force` 的由来(**真机走查抓到的真缺陷**):规格要求「页面不可见时停止计算」,
  1132. * 原实现把 `document.hidden` 写成无条件早退 —— 于是标签页在后台时**连首次布局都不做**,
  1133. * 面板永远停在初值 `canvas-missing` 上(走查环境正是这种标签页,整条锚定链路不可观测)。
  1134. * 正确的分工是:
  1135. * · **逐帧**重算(`force` 未设)在页面不可见时跳过 —— 那是规格要的省算力;
  1136. * · **显式**重算(详情选择变化 / 尺寸变化 / 挂载,`force: true`)照常执行 ——
  1137. * 它由用户动作驱动、次数极少,跳过只会让面板停留在错误位置。
  1138. */
  1139. function refreshAnchor(options = {}) {
  1140. if (!detailOpen.value || introVisible.value || typeof document === 'undefined') return
  1141. if (document.hidden && options.force !== true) return
  1142. const main = detailRef.value ? detailRef.value.closest('.cockpit-shell__main') : null
  1143. if (main) {
  1144. const rect = main.getBoundingClientRect()
  1145. anchorOrigin.value = { left: rect.left, top: rect.top }
  1146. }
  1147. const { layout } = measureAnchor()
  1148. const key = layout.visible
  1149. ? `${layout.side}|${Math.round(layout.rect.left)},${Math.round(layout.rect.top)},${Math.round(layout.rect.width)},${Math.round(layout.rect.height)}|${JSON.stringify(layout.line)}`
  1150. : layout.reason
  1151. // 与上一帧相同就**不再写 DOM**(规格:镜头稳定且布局未变时停止重算)。
  1152. if (key === lastAnchorKey) return
  1153. lastAnchorKey = key
  1154. anchorLayout.value = layout
  1155. }
  1156. /**
  1157. * 面板落位。**用绝对定位**(不是 `fixed`):百分比 `max-height` 要按 `main` 解析,
  1158. * 而 `fixed` 会按视口解析 —— 视口比地图区多出顶栏与筛选条那一条,阈值就会漂。
  1159. */
  1160. const detailStyle = computed(() => {
  1161. if (fixedLocationReason.value) return {}
  1162. const layout = anchorLayout.value
  1163. // 暂隐:`visibility` 而不是 `display:none` —— 元素仍在 DOM 里,恢复时能直接量到自然高度
  1164. // (`display:none` 会让 `scrollHeight` 变 0,恢复后面板会先塌成 1px)。
  1165. // **必须同时关掉 `pointer-events`**:本组件是 `pointer-events: auto` 的绝对定位浮层,
  1166. // 只隐藏视觉仍会**拦截它覆盖区域内的地图点击**(看不见却吃掉交互)。
  1167. if (!layout.visible) return { visibility: 'hidden', pointerEvents: 'none' }
  1168. const origin = anchorOrigin.value
  1169. return {
  1170. left: `${layout.rect.left - origin.left}px`,
  1171. top: `${layout.rect.top - origin.top}px`,
  1172. width: `${layout.rect.width}px`,
  1173. // 内容没超过可读上限就按内容高(不留空白),超了才钉住高度并由卡内滚动接管。
  1174. height: layout.cropped ? `${layout.rect.height}px` : 'auto',
  1175. }
  1176. })
  1177. /** 归属短线:从图标外缘指向卡片面对图标的那条边(不覆盖图标)。 */
  1178. const anchorLeadStyle = computed(() => {
  1179. const line = anchorLayout.value.line
  1180. if (!line) return {}
  1181. // 与卡片同一坐标基准:投影给的是**窗口坐标**,短线与卡片同处 `main` 内(绝对定位),
  1182. // 所以两端都要减掉基准点 —— 只减卡片那一处会让线整体偏出一个顶栏+筛选条的高度。
  1183. const origin = anchorOrigin.value
  1184. const from = { x: line.from.x - origin.left, y: line.from.y - origin.top }
  1185. const to = { x: line.to.x - origin.left, y: line.to.y - origin.top }
  1186. const dx = to.x - from.x
  1187. const dy = to.y - from.y
  1188. const length = Math.hypot(dx, dy)
  1189. const angle = (Math.atan2(dy, dx) * 180) / Math.PI
  1190. return {
  1191. left: `${from.x}px`,
  1192. top: `${from.y}px`,
  1193. width: `${length}px`,
  1194. transform: `rotate(${angle}deg)`,
  1195. }
  1196. })
  1197. /** 暂隐入口的「重新定位」:只对仍有效的监测点标记走统一选择入口(不发明第二条定位路径)。 */
  1198. function relocateSelected() {
  1199. const target = detailTarget.value
  1200. if (!target) return
  1201. if (target.kind === DEVICE_LAYER_KIND) deviceLayerStore.selectDevice(target.code)
  1202. else if (target.kind === MAP_OBJECT_KIND.MONITORING_POINT) selectMonitoringPoint(target.code)
  1203. }
  1204. /**
  1205. * 详情选择或地图对象变化 → 立即重算一次落位(不等下一帧)。
  1206. * `nextTick` 是等卡片挂载/换内容后再量高度:刚打开时 `scrollHeight` 还是 0,
  1207. * 直接量会把长卡判成"没内容"而永远按 auto 摆放。
  1208. */
  1209. watch(
  1210. [() => selection.value.level, () => selection.value.pointCode, () => selection.value.deviceCode, () => mapStore.summary.objects, () => deviceRows.value],
  1211. () => {
  1212. lastAnchorKey = ''
  1213. nextTick(() => refreshAnchor({ force: true }))
  1214. },
  1215. { deep: false },
  1216. )
  1217. // ---- 共享数据分区(票 05):三条共享详情域分区在筛选条右侧列出状态 ----
  1218. // 标签用短名,状态语义仍由 SectionStateTag + domain/sectionState.js 决定。
  1219. const SECTION_LABELS = {
  1220. mapSnapshot: '地图摘要',
  1221. equipmentAggregate: '设备详情',
  1222. equipmentFreshness: '数据新鲜度',
  1223. }
  1224. const sharedSections = computed(() =>
  1225. Object.keys(SECTION_LABELS).map((name) => ({
  1226. name,
  1227. label: SECTION_LABELS[name],
  1228. section: sections.value[name],
  1229. })),
  1230. )
  1231. /**
  1232. * 票 16 验收第 2 条要求「新鲜、过期、不可用和**部分失败**状态可区分」。
  1233. *
  1234. * 「部分失败」是**跨分区**的性质:单个分区只会说自己那一份(`sectionView`),
  1235. * 谁也不负责说"三块里坏了一块"。所以这里把各分区的四态收上来交给
  1236. * `domain/freshnessModel.js` 的 `overallFreshness()`,由它给出整体结论 ——
  1237. * 判据只有一份,界面不自己数数。
  1238. */
  1239. const overallFreshnessState = computed(() =>
  1240. overallFreshness(sharedSections.value.map((item) => viewFromSectionState(sectionStateOf(item.section)))),
  1241. )
  1242. const overallFreshnessLabel = computed(() => overallFreshnessText(overallFreshnessState.value))
  1243. // ---- 地图摘要渐进加载(票 08):顶栏只展示状态与一个动作,分页/快照参数都在适配层 ----
  1244. const mapSummaryView = computed(() => mapStore.view)
  1245. /** 收起态只留一条最新状态,展开态才显示全部诊断字段。 */
  1246. const statusSummaryText = computed(() => {
  1247. if (overallFreshnessState.value === 'partial') return overallFreshnessLabel.value
  1248. if (['empty', 'stale', 'unavailable'].includes(runtimeStore.freshness.view)) return runtimeStore.freshness.text
  1249. if (runtimeStore.status.state !== 'open') return runtimeStore.statusText
  1250. if (['idle', 'loading', 'empty', 'failed', 'expired'].includes(mapSummaryView.value.phase)) return mapSummaryView.value.label
  1251. return `更新 ${runtimeStore.freshness.lastSuccessClock}`
  1252. })
  1253. const statusSummaryTone = computed(() => {
  1254. if (overallFreshnessState.value === 'partial') return 'is-warning'
  1255. if (['empty', 'stale', 'unavailable'].includes(runtimeStore.freshness.view)) return 'is-warning'
  1256. if (runtimeStore.status.state !== 'open') return 'is-warning'
  1257. if (['idle', 'loading', 'empty', 'failed', 'expired'].includes(mapSummaryView.value.phase)) return 'is-warning'
  1258. return 'is-ok'
  1259. })
  1260. /**
  1261. * 票 14:手动刷新(状态栏最右侧的刷新动作,决策 02 的 Q25)=换新快照并**自动从第 1 页加载到完成**
  1262. * (旧「加载下一页 / 刷新快照」两个按钮已移除;自动链归 mapStore 的装载链)。
  1263. */
  1264. async function refreshMapSnapshot() {
  1265. await mapStore.refresh()
  1266. // 地图摘要分区状态跟着装载状态走(票 05 的分区板据此变色)。
  1267. detailStore.syncMapSnapshotSection()
  1268. }
  1269. /**
  1270. * 票 14(规格 09 的 tick 全套):新鲜度周期的面板半边 —— 刷新**当前一级模式**的全部面板。
  1271. * 面板取数失败不抛(各分区自己收成 UNAVAILABLE 并如实标注),地图刷新失败才是
  1272. * tick 记账的失败来源;两者都不做无限重试(决策 04:恢复靠周期与手动入口)。
  1273. */
  1274. async function refreshCurrentModePanels() {
  1275. const current = mode.value
  1276. if (current === 'monitoring') {
  1277. await monitoringStore.loadPanels({
  1278. industry: industry.value,
  1279. deviceType: deviceType.value,
  1280. pointStatus: pointStatus.value,
  1281. scope: scope.value,
  1282. })
  1283. } else if (current === 'infrastructure') {
  1284. await infrastructureStore.loadPanels({ industry: industry.value, scope: scope.value })
  1285. } else if (current === 'warningResponse') {
  1286. // 票 67:片区也要跟着下(预警分析是事件族里唯一收片区入参的端点)。
  1287. await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value, scope: scope.value })
  1288. } else {
  1289. await overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
  1290. }
  1291. }
  1292. // ---- 配置状态(票 04):载入前不渲染标签,避免先闪一下「缺失」再变「就绪」 ----
  1293. const configLoaded = ref(false)
  1294. const configState = ref(null)
  1295. const configView = computed(() => configStatusView(configState.value))
  1296. /**
  1297. * 票 17:字体授权门禁的宿主读数(走查断言接缝)。
  1298. * `fontStatus()` 与 `installFontFaces()` 同源——都读统一资产解析层的门禁判定,
  1299. * 所以读数不会与页面实际注入的 @font-face 出现两套说法。
  1300. */
  1301. const cockpitFontStatus = computed(() => fontStatus())
  1302. const fontGate = computed(() => (cockpitFontStatus.value.ok ? 'ok' : cockpitFontStatus.value.code))
  1303. const fontFaceCount = computed(() => String(cockpitFontStatus.value.entries.filter((e) => e.resolvable).length))
  1304. /**
  1305. * 票 17 第七轮:把导航装饰切图(选中态底板 / 选中光晕 / 底边光路)经语义 key 解析成
  1306. * CSS 自定义属性挂在顶栏上——样式文件里只引用 var(),不出现物理路径;
  1307. * manifest 缺条目时变量为 none,对应装饰自动退化(底板退 CSS 渐变、光晕/光路不画)。
  1308. * 票 18(批次 5):新增品牌标题后方光池(icon.brand.pool),同样语义注入、缺失自动不画。
  1309. */
  1310. const navDecorStyle = computed(() => {
  1311. const bg = iconFor('navActiveBg')
  1312. const glow = iconFor('brandGlow')
  1313. const path = iconFor('brandGlowPath')
  1314. const pool = iconFor('brandPool')
  1315. return {
  1316. '--nav-active-bg': bg && bg.url ? `url("${bg.url}")` : 'none',
  1317. '--nav-glow-url': glow && glow.url ? `url("${glow.url}")` : 'none',
  1318. '--brand-path-url': path && path.url ? `url("${path.url}")` : 'none',
  1319. '--brand-pool-url': pool && pool.url ? `url("${pool.url}")` : 'none',
  1320. }
  1321. })
  1322. /**
  1323. * 票 32:UI 形态。配置 `map.ui.debug` 显式给布尔值就听它,否则跟随构建模式
  1324. * —— 开发构建显示诊断信息,生产构建自动收敛成「干净模式」(维护者 2026-09-18 要求)。
  1325. */
  1326. const uiDebug = computed(() => {
  1327. const configured =
  1328. configState.value && configState.value.config && configState.value.config.map && configState.value.config.map.ui
  1329. ? configState.value.config.map.ui.debug
  1330. : null
  1331. if (typeof configured === 'boolean') return configured
  1332. return !!(import.meta.env && import.meta.env.DEV)
  1333. })
  1334. // ---- 进入引导与视角预设(票 30)----
  1335. // 引导默认开着;配置里 map.intro.enabled=false 时直接进系统(其它票的走查用它自隔离)。
  1336. // `intro`/`introVisible` 的声明已上移至 `modeLayerPlan` 之前(TDZ,见上移处的说明)。
  1337. const atHomeView = ref(false)
  1338. const homeView = computed(() => getHomeView())
  1339. const homeTitle = computed(() => `回到${homeLabel(homeView.value)}`)
  1340. // ---- 主场景区:进区锁定与区域限位(票 37 / ADR 0009)----
  1341. //
  1342. // 状态本体在 `filterStore.areaState`(全局视图条件,切一级模式不重置);服务层负责转移。
  1343. // 外壳只做三件它才有资格做的事:顶栏按钮形态、闲置计时、引导页显隐。
  1344. const areaLocked = computed(() => areaState.value === AREA_STATE.LOCKED)
  1345. const areaBar = computed(() => areaBarActions(areaState.value))
  1346. /** 生效限位(区外是宽区间;区内上限=home.range)。按钮提示里读它,不另算一套。 */
  1347. const areaLimits = ref(getAreaLimits())
  1348. /**
  1349. * 进区受阻时的如实提示(主场景区未配置 / 非法)。
  1350. * **不弹窗、不遮罩**:与票 04/05 的立场一致 —— 单个能力不可用只标出来,不挡业务。
  1351. */
  1352. const areaNotice = ref('')
  1353. /** 把服务层的区域状态同步回 store(服务层是转移的判定者,store 是持有者)。 */
  1354. function syncAreaState() {
  1355. const next = getAreaState()
  1356. if (next !== areaState.value) filterStore.setAreaState(next)
  1357. areaLimits.value = getAreaLimits()
  1358. fogReadout.value = getFogState()
  1359. }
  1360. // ---- 票 38:自建视距雾的可观测读数 ----
  1361. //
  1362. // 外壳**只读**服务层的读数,不自己算强度/支持度(策略与生命周期归 mapService)。
  1363. const fogReadout = ref(getFogState())
  1364. /** 芯片文案:支持/可执行/强度三项说清楚;降级时说降级原因。 */
  1365. const fogText = computed(() => {
  1366. const fog = fogReadout.value
  1367. if (fog.state === 'unsupported') return '视距雾不可用 · 无深度支持'
  1368. if (fog.state === 'failed') return '视距雾不可用'
  1369. if (fog.degrade === 'reduced') return `视距雾减弱 · 强度 ${fog.strength.toFixed(2)}`
  1370. if (fog.degrade === 'off') return '视距雾已暂关'
  1371. if (fog.phase === 'global') return '视距雾待命 · 区内生效'
  1372. return `视距雾 ${fog.strength.toFixed(2)} · ${Math.round(fog.startDistance / 1000)}–${Math.round(fog.endDistance / 1000)} km`
  1373. })
  1374. /** 悬停说明:把"就绪不等于已遮住边缘"这条口径写在人能读到的地方。 */
  1375. const fogTitle = computed(() => {
  1376. const fog = fogReadout.value
  1377. const lines = [
  1378. `雾色 ${fog.color}`,
  1379. `起雾 ${Math.round(fog.startDistance)} m · 完全遮蔽 ${Math.round(fog.endDistance)} m`,
  1380. `阶段 ${fog.phase} · 强度 ${fog.strength}`,
  1381. '「就绪」只表示处理级可执行,不代表矩形边缘已被遮住',
  1382. ]
  1383. if (fog.reason) lines.push(fog.reason)
  1384. return lines.join(';')
  1385. })
  1386. /**
  1387. * 返回全球(ADR 0009):解除限位 + 阻尼飞到程序初始态全景,之后完全自由。
  1388. * 引擎未就绪时服务层返回 `applied: false` —— 如实不生效,不假装飞了。
  1389. */
  1390. function returnToGlobal() {
  1391. const result = setAreaState('returnGlobal')
  1392. syncAreaState()
  1393. if (result.applied) {
  1394. // 回全球后不久就该由闲置计时接管(回引导页),先把按钮状态刷一次。
  1395. window.setTimeout(refreshHomeState, 900)
  1396. }
  1397. return result
  1398. }
  1399. /**
  1400. * 进区(点「进入系统」/「归位」时随 `goHome` 一起发生)。
  1401. * **主场景区不可用时进不去**:服务层返回 blocked,这里如实说明,不假装锁上了
  1402. * (票 36 的关闭路径 —— 未配置 / 顺序非法时限位与雾气本来就该整体关闭)。
  1403. */
  1404. function enterArea() {
  1405. const result = setAreaState('enter')
  1406. syncAreaState()
  1407. return result
  1408. }
  1409. /** 进入系统:停自转 + 进区 + 飞到目标城市预设视角。 */
  1410. function enterSystem() {
  1411. introVisible.value = false
  1412. setGlobeSpin(0)
  1413. enterArea()
  1414. // 进区失败(主场景区未配置/非法)也要说一句:否则点了「进入系统」什么都没发生。
  1415. if (!areaLocked.value) areaNotice.value = '主场景区未配置或配置无效 · 区域限位与雾气关闭'
  1416. // 引导层可能比引擎先就绪(引擎要拉地形/影像):没下发成功就重试,直到相机接上。
  1417. let tries = 0
  1418. const fly = () => {
  1419. tries += 1
  1420. if (goHome({ immediate: false }) || tries >= 40) {
  1421. window.setTimeout(refreshHomeState, 900)
  1422. return
  1423. }
  1424. window.setTimeout(fly, 250)
  1425. }
  1426. fly()
  1427. window.setTimeout(refreshHomeState, 900)
  1428. }
  1429. function refreshHomeState() {
  1430. atHomeView.value = atHome()
  1431. // 票 37:区域状态也在这里对齐。服务层是转移的判定者(`goHome` 内部会进区),
  1432. // 而 DEV 把手 `__cockpitMap.goHome()` / `enterArea()` 走的是**服务层直调**,不经过本组件的
  1433. // `enterArea()` —— 不同步的话,走查用把手进区后顶栏仍显示「归位/进入」、也不出「返回全球」,
  1434. // 与实际锁定状态不符(本票真机走查第一次跑就撞上了)。这个 500ms 轮询是既有的,
  1435. // 借用它让两条入口最终一致,不新增定时器。
  1436. syncAreaState()
  1437. }
  1438. /** 归位按钮:引擎未就绪时重试(与进入系统同一条路径)。 */
  1439. function returnHome() {
  1440. // 票 37:归位即进区(服务层的 `goHome` 内部也调一次,幂等);不可用时如实提示。
  1441. enterArea()
  1442. if (!areaLocked.value) areaNotice.value = '主场景区未配置或配置无效 · 区域限位与雾气关闭'
  1443. let tries = 0
  1444. const fly = () => {
  1445. tries += 1
  1446. if (goHome({ immediate: false }) || tries >= 40) {
  1447. window.setTimeout(refreshHomeState, 900)
  1448. return
  1449. }
  1450. window.setTimeout(fly, 250)
  1451. }
  1452. fly()
  1453. }
  1454. const columns = computed(() => columnsForMode(mode.value))
  1455. function isExpanded(panelId) {
  1456. return expandedPanelIds.value.includes(panelId)
  1457. }
  1458. // ---- 全球态闲置 → 回程序初始态(票 37 / ADR 0009)----
  1459. //
  1460. // 「全球态闲置 5 秒未操作 → 回程序初始态(星球自转恢复 + 屏幕中央闪烁『点击进入系统』)」。
  1461. // 三条边界都是契约明写的,写在代码里免得日后被"优化"掉:
  1462. // 1. **只在 global 态计时** —— 区内是工作状态,不该被闲置踢回引导页;
  1463. // 2. **任何交互即重置**(指针/滚轮/键盘/点按钮都算);
  1464. // 3. **不重置一级模式与筛选**(只回视角与引导层,不动工作上下文)。
  1465. // `map.intro.enabled=false` 时不发生(没有引导页可回 —— spec 的边缘口径)。
  1466. let idleSince = 0
  1467. let idleTimer = null
  1468. /** 记一次交互(重置闲置计时)。任何用户动作都应调它。 */
  1469. function markActivity() {
  1470. idleSince = Date.now()
  1471. }
  1472. /**
  1473. * 回到程序初始态:相机回初始全景 + 恢复自转 + 显示引导层
  1474. * (不动 mode / 筛选 / 区域状态)。
  1475. *
  1476. * 维护者 2026-09-30 报障:原先只显示引导层 + 起转,**没有把相机送回去** ——
  1477. * 于是画面停在用户最后停留的视角上、地球也不转,除了中央那行字就看不出来"回引导态了"。
  1478. * 现在三件事一起做:`resetToIntroView()` 把角度送回 `0/−60/2×10⁷`,
  1479. * `setGlobeSpin()` 立起自转意图(相机若还在飞,落定后由服务层自动接上)。
  1480. */
  1481. function returnToIntro() {
  1482. introVisible.value = true
  1483. resetToIntroView()
  1484. setGlobeSpin(intro.value.spinSeconds)
  1485. // 回引导页不等于解除限位:区外本来就是 global,区内不该被动摇(契约只说全球态闲置)。
  1486. markActivity()
  1487. }
  1488. function checkIdle() {
  1489. if (!idleSince) {
  1490. markActivity()
  1491. return
  1492. }
  1493. const elapsed = Date.now() - idleSince
  1494. if (shouldReturnToIntro(elapsed, getAreaState(), { idleMs: GLOBAL_IDLE_MS, introEnabled: intro.value.enabled })) {
  1495. if (!introVisible.value) returnToIntro()
  1496. // 已经在引导页:继续重置,避免每 250ms 重复触发。
  1497. markActivity()
  1498. }
  1499. }
  1500. /**
  1501. * 全局交互监听:任何指针/滚轮/键盘动作都重置闲置计时。
  1502. * 捕获阶段监听,免得被面板自己的 `stopPropagation` 吃掉。
  1503. */
  1504. function onAnyActivity() {
  1505. markActivity()
  1506. }
  1507. // ---- 顶栏时钟 ----
  1508. const clockText = ref('')
  1509. let clockTimer = null
  1510. let homeTimer = null
  1511. function tick() {
  1512. const now = new Date()
  1513. const pad = (n) => String(n).padStart(2, '0')
  1514. clockText.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
  1515. }
  1516. /**
  1517. * 票 41:锚定跟随的帧驱动与恢复。
  1518. *
  1519. * `onMapFrame` 走 Cesium 的 `scene.postRender`(**真的渲染了一帧**才回调)——
  1520. * 不是自己再排一个 rAF:隐藏标签页里 rAF 会被冻结,而 `postRender` 只在渲染时触发,
  1521. * 与「每帧最多一次、画面已渲染才投影」的规格口径一致。
  1522. * 这里只对**当前一个**锚点做投影,没有详情时回调本身就是早退(零开销)。
  1523. */
  1524. let stopMapFrame = null
  1525. let stopCameraState = null
  1526. let anchorResizeHandler = null
  1527. onMounted(() => {
  1528. tick()
  1529. clockTimer = setInterval(tick, 1000)
  1530. // 归位按钮的高亮状态跟着相机走(用户手动平移后就不再算"在家")。
  1531. homeTimer = setInterval(refreshHomeState, 500)
  1532. // 票 17:字体面只在这个入口注入一次,随外壳生命周期存亡(幂等,重复调用不会叠加节点)。
  1533. installFontFaces()
  1534. // 票 37:闲置计时只在这个入口挂一次,随外壳生命周期存亡。
  1535. markActivity()
  1536. idleTimer = setInterval(checkIdle, 250)
  1537. for (const domEvent of ['pointerdown', 'pointermove', 'wheel', 'keydown']) {
  1538. window.addEventListener(domEvent, onAnyActivity, { capture: true, passive: true })
  1539. }
  1540. // 票 04:配置只在这个入口载入一次(服务内部有缓存),地图服务按需 await 同一份。
  1541. loadCockpitConfig().then((result) => {
  1542. configState.value = result
  1543. configLoaded.value = true
  1544. // 配置到手才知道引导开关与预设坐标,重新取一次。
  1545. intro.value = getIntroConfig()
  1546. introVisible.value = intro.value.enabled
  1547. // ── 票 14:按**启动计划**执行(规格 09 / 决策票 04)──
  1548. //
  1549. // `dataSource` 只决定 transport,不决定页面生命周期:real 与 mock 产出**同一套动作**
  1550. // (地图装载器、共享分区、四模式面板、WS、新鲜度),差别只剩 mock 分支多一步
  1551. // `register-mocks`。此前的 `if (dataSource === MOCK)` 门让 real 成为空壳——
  1552. // 规格明令移除;失败语义(分区 UNAVAILABLE / NO_BASE_URL)由 requestCockpitJson
  1553. // 与 sectionState 对两种数据源一视同仁,**没有**任何「real 失败回退 mock」的路径。
  1554. const plan = planCockpitStartup(result.config)
  1555. for (const action of plan.actions) {
  1556. if (action === STARTUP_ACTION.REGISTER_MOCKS) {
  1557. ensureCockpitMocks()
  1558. continue
  1559. }
  1560. // 票 18:**先把筛选交给地图**,再让共享分区去取第 1 页(顺序纪律不变)。
  1561. // 若筛选晚于它,第 1 页就带着空筛选发出去 —— 那正是"筛了但第一屏没筛上"的经典来源。
  1562. // (`mapStore.setup()` 是合并语义,所以后到的调用不会抹掉这里的条件。)
  1563. // 票 14:refresh 链会**自动分批加载到完成**(决策 02 的 Q24)。
  1564. if (action === STARTUP_ACTION.LOAD_MAP_SUMMARY) {
  1565. mapStore.setup({
  1566. config: result.config,
  1567. industry: industry.value,
  1568. scope: scope.value,
  1569. deviceType: deviceType.value,
  1570. pointStatus: pointStatus.value,
  1571. })
  1572. continue
  1573. }
  1574. if (action === STARTUP_ACTION.LOAD_SHARED_SECTIONS) {
  1575. // 票 14:共享分区预载也吃**同一份**启动配置。`loadSharedSections` 内部会对
  1576. // mapStore 再做一次合并式 setup —— 不显式下发的话,`shared = { config: undefined }`
  1577. // 会把 LOAD_MAP_SUMMARY 刚合并进 mapStore 的 config 键覆盖掉(合并语义下
  1578. // `undefined` 也是一次写入),real 下首批摘要请求就只能靠配置缓存兜底了。
  1579. detailStore.loadSharedSections({ config: result.config })
  1580. // 票 24:设施点位与管线是**跨模式共享**的图层(不随一级模式切换重取),
  1581. // 所以跟共享分区一起启动,而不是塞进某个模式的 loadPanels。
  1582. // 票 67 更正:片区也要交给管线图层(本地按编码前缀裁剪,不额外发请求)。
  1583. pipeNetworkStore.load({ industry: industry.value, scope: scope.value, config: result.config })
  1584. continue
  1585. }
  1586. // 筛选用全局条件的当前值(CONTEXT.md:行业筛选作用于整个驾驶舱)。
  1587. if (action === STARTUP_ACTION.LOAD_INFRASTRUCTURE_PANELS) {
  1588. infrastructureStore.configure({ config: result.config })
  1589. infrastructureStore.loadPanels({ industry: industry.value, scope: scope.value })
  1590. continue
  1591. }
  1592. if (action === STARTUP_ACTION.LOAD_MONITORING_PANELS) {
  1593. monitoringStore.configure({ config: result.config })
  1594. monitoringStore.loadPanels({
  1595. industry: industry.value,
  1596. deviceType: deviceType.value,
  1597. pointStatus: pointStatus.value,
  1598. scope: scope.value,
  1599. })
  1600. continue
  1601. }
  1602. if (action === STARTUP_ACTION.LOAD_EVENT_PANELS) {
  1603. eventStore.configure({ config: result.config })
  1604. eventStore.loadPanels({ mode: 'WARNING', range: '7d', industry: industry.value })
  1605. continue
  1606. }
  1607. if (action === STARTUP_ACTION.LOAD_OVERVIEW_PANELS) {
  1608. overviewStore.configure({ config: result.config })
  1609. overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
  1610. continue
  1611. }
  1612. // 票 15:状态推送。外壳只说"用配置启动"——地址、凭据与 mock/real 的连接方式
  1613. // 都由 runtimeStore 决定(页面因此不认识凭据,也不认识假连接)。
  1614. // 没配地址时服务进 `disabled`,界面如实说"未配置",不假装连上。
  1615. if (action === STARTUP_ACTION.START_WEBSOCKET) {
  1616. runtimeStore.startWithConfig(result.config, {
  1617. scenario: (typeof window !== 'undefined' && window.__COCKPIT_WS_SCENARIO__) || 'success',
  1618. })
  1619. continue
  1620. }
  1621. // 票 16/14:刷新周期(决策地图票 07 Q3 = 前端轮询 + WS 断线也算失败周期)。
  1622. // 规格 09 的 tick 全套:地图摘要刷新 + **当前模式面板取数**,成败交给新鲜度记账;
  1623. // 连续两个周期失败即过期(有旧值)或不可用(从未取到)。
  1624. // `__COCKPIT_FRESHNESS_FAIL__` 让走查/人工演示"周期一直失败"这条路径。
  1625. if (action === STARTUP_ACTION.START_FRESHNESS) {
  1626. const failForever = typeof window !== 'undefined' && window.__COCKPIT_FRESHNESS_FAIL__ === true
  1627. runtimeStore.startFreshness({
  1628. refreshSeconds: result.config.runtime ? result.config.runtime.refreshSeconds : undefined,
  1629. tick: async () => {
  1630. if (failForever) return { ok: false, reason: '演示:取数持续失败' }
  1631. try {
  1632. await mapStore.refresh()
  1633. await refreshCurrentModePanels()
  1634. return { ok: true }
  1635. } catch (error) {
  1636. return { ok: false, reason: String((error && error.message) || error) }
  1637. }
  1638. },
  1639. })
  1640. }
  1641. }
  1642. lifecycleReady.value = true
  1643. })
  1644. // 引导期间显示自转;进入后停(引擎就绪前调用会被忽略,等引擎 ready 再兜一次)。
  1645. if (intro.value.enabled) {
  1646. setGlobeSpin(intro.value.spinSeconds)
  1647. const spinTimer = setInterval(() => {
  1648. if (!introVisible.value || setGlobeSpin(intro.value.spinSeconds)) clearInterval(spinTimer)
  1649. }, 600)
  1650. } else {
  1651. // 票 37:`intro.enabled=false`(走查自隔离)时**按 global 自由视角启动、不弹引导页**
  1652. // (ADR 0009 的边缘口径)。这里显式同步一次状态,免得服务层与 store 各持一个初值。
  1653. syncAreaState()
  1654. }
  1655. // 票 41:锚定跟随。帧驱动走地图的已渲染帧回调;尺寸变化与详情选择变化各补一次立即重算
  1656. // (规格:恢复或尺寸变化后立即重算,不等下一帧)。
  1657. stopMapFrame = onMapFrame(() => refreshAnchor())
  1658. // 相机状态变化也重算(隐藏标签页里渲染帧不触发,这条才是可靠的跟随路径;见 mapService 的注释)。
  1659. // 它由输入/飞行驱动、次数有限,所以同样走 `force`(不受"页面不可见"早退影响)。
  1660. stopCameraState = onCameraStateChange(() => refreshAnchor({ force: true }))
  1661. anchorResizeHandler = () => refreshAnchor({ force: true })
  1662. window.addEventListener('resize', anchorResizeHandler)
  1663. // 票 41:把统一选择入口递给 DEV 把手(走查/人工演示走同一条路径,不另造定位入口)。
  1664. publishPointSelectionHandle(selectMonitoringPoint)
  1665. // 票 43:设备选中/hover 的 DEV 把手也走 store(避免服务层与 store 出现两套选中真相)。
  1666. publishDeviceSelectionHandle({
  1667. selectDevice: (code) => deviceLayerStore.selectDevice(code),
  1668. hoverDevice: (code) => deviceLayerStore.hoverDevice(code),
  1669. backToPointLevel: () => deviceLayerStore.backToPointLevel(),
  1670. })
  1671. refreshAnchor({ force: true })
  1672. // 票 42:三类触发都接上(选择 / 详情到达 / 快照变化)。
  1673. // 选择与详情分开监听是有意的:选择变化要**立即**撤旧图层,而详情到达只需重算同一份计划。
  1674. stopDeviceWatches.push(
  1675. watch(() => [selection.value.pointCode, selection.value.level], () => syncDeviceLayer({ force: true }), { deep: false }),
  1676. watch(modeAbnormalDevices, () => syncDeviceLayer(), { deep: false }),
  1677. watch(() => sections.value.pointDetail, () => syncDeviceLayer(), { deep: false }),
  1678. watch(() => mapStore.summary.batchUpdatedAt, (stamp) => syncDeviceLayerForSnapshot(stamp), { deep: false }),
  1679. // A filter reload can retain the backend timestamp while changing device membership.
  1680. watch(() => mapStore.summary.objects, () => syncDeviceLayer(), { deep: false }),
  1681. // 票 42(规格 14:刷新核对链)——三个时刻:
  1682. // ① 刷新**发起**:立即挂起设备图层(规格「先撤下」,不等首页返回);
  1683. // ② 快照**失效**:expired 相位不改 batchUpdatedAt,必须单独触发撤层;
  1684. // ③ 链条 **settled**:对挂起中的所选点位核对收口(找到重载 / 完整找不到关闭 / 失败保持待核对)。
  1685. watch(() => mapStore.refreshing, (on) => {
  1686. if (!on) return
  1687. deviceLayerStore.suspendForSnapshot()
  1688. snapshotAbsentNote.value = ''
  1689. snapshotPending.value = Boolean(selection.value.pointCode)
  1690. }, { deep: false }),
  1691. watch(() => mapStore.summary.phase, (phase) => {
  1692. if (phase !== MAP_SUMMARY_PHASE.EXPIRED || !selection.value.pointCode) return
  1693. deviceLayerStore.suspendForSnapshot()
  1694. snapshotPending.value = true
  1695. }, { deep: false }),
  1696. watch(() => mapStore.settled, (settled) => {
  1697. if (settled && snapshotPending.value) reconcileSnapshotSelection()
  1698. }, { deep: false }),
  1699. // 一级模式变化只改**可见性与选择态**(不换快照、不重建地图):
  1700. // 先按矩阵执行选择态语义(总览↔分项保留 / 分项↔分项清空),再重算设备图层计划。
  1701. watch(mode, (next, prev) => {
  1702. applyModeSelectionTransition(prev, next)
  1703. syncDeviceLayer({ force: true })
  1704. }, { deep: false }),
  1705. // 票 46:引导态 ⇄ 进入系统的门控(规格 18:entered 决定一切业务对象显隐)。
  1706. watch(introVisible, (next) => syncLayersForEntered(!next), { deep: false }),
  1707. // 票 43:详情层级写进宿主 `data-map-panel-selection`,并把层级对照同步给设备图层 store
  1708. // (镜头联动判"该怎么动"要拿它当"变化前")。
  1709. watch(
  1710. () => [selection.value.level, selection.value.deviceCode],
  1711. ([level, deviceCode]) => {
  1712. setPanelSelectionLevel(level)
  1713. deviceLayerStore.noteLevel(level, deviceCode)
  1714. },
  1715. { deep: false, immediate: true },
  1716. ),
  1717. )
  1718. })
  1719. /**
  1720. * 票 17:全局行业筛选变化 → 重取四块面板(CONTEXT.md:行业筛选是作用于整个驾驶舱的全局条件,
  1721. * 所以它必须真的作用到面板数据上,而不只是记在 store 里)。
  1722. *
  1723. * 只在 mock 取数已就绪后跟随:配置还没落地时 store 里还没有 config,
  1724. * 这时抢跑会带着空配置发请求(票 04 的「没有显式配置就不发请求」)。
  1725. */
  1726. watch(industry, (next) => {
  1727. if (!lifecycleReady.value) return
  1728. infrastructureStore.loadPanels({ industry: next })
  1729. // 票 18:运行监测三块也跟随;地图换新快照重载(Q9:丢弃旧范围的已加载对象)。
  1730. monitoringStore.loadPanels({ industry: next })
  1731. // 票 13:当前预警的事件列表跟随全局行业(`/events/current` 收 industry 入参)。
  1732. eventStore.loadPanels({ mode: eventStore.mode, industry: next })
  1733. // 票 20:概览只有「运行监测」那块收行业入参(另两块不收,store 内部只重取能筛的那块)。
  1734. overviewStore.loadPanels({ industry: next })
  1735. // 票 24:行业筛选同样作用到管线与构件图层。几何是**全量取回、本地裁剪**,
  1736. // 所以切行业只改本地筛选条件、不重新发请求(切换必须即时,不等一个往返)。
  1737. pipeNetworkStore.setIndustry(next)
  1738. refreshMapForFilters({ industry: next })
  1739. })
  1740. /**
  1741. * 片区筛选变化:地图、监测分布、基础档案、管理计数、概览片区分布与**预警分析**同步刷新。
  1742. *
  1743. * 票 67 补的不是"所有面板都跟着变",而是**"该跟着变的都跟着变、不该变的都说明白"**:
  1744. * - 真收片区入参的端点:`map-snapshot` / 基础档案 / 管理信息 / 监测分布 /
  1745. * 预警分析(`/events/multidimensional-analysis`)→ 在这里与下方 store 里接线;
  1746. * - 不收片区入参的端点(当前事件/历史事件/处置效率/监测概览/运行概况/运行信息/
  1747. * 应急保障/概览端点/几何读模型)→ **不下发参数**,由 `domain/filterCaliber.js`
  1748. * 在对应面板的口径说明里如实交代(旧版这里是静默忽略:切了片区什么都没变、也没说为什么)。
  1749. * `eventStore` 原本整条旁路:切片区后预警处置四块面板一个请求都不发(票 67 的缺口 2)。
  1750. */
  1751. watch(scope, (next) => {
  1752. if (!lifecycleReady.value) return
  1753. infrastructureStore.loadPanels({ scope: next })
  1754. overviewStore.loadPanels({ scope: next })
  1755. monitoringStore.loadPanels({ scope: next })
  1756. // 换片区 = 换一批数据 ⇒ 页码回第 1 页由 eventStore 的条件键(含 `scope`)负责。
  1757. eventStore.loadPanels({ mode: eventStore.mode, scope: next })
  1758. // 票 67 更正(维护者 2026-10-23):管线与构件**也能按片区筛** —— 片区从设施编码前缀推
  1759. // (片区+行业+时间编码)。几何是全量取回、本地裁剪,所以这里只改条件、不重新发请求。
  1760. pipeNetworkStore.setScope(next)
  1761. refreshMapForFilters({ scope: next })
  1762. })
  1763. /**
  1764. * 票 14:点位状态筛选变化 → 面板与**一张图**都跟随(决策 02/规格 07 冻结的契约:
  1765. * `map-snapshot` 收多值 `pointStatus`,旧「状态筛选不作用到一张图」的缺口已关闭)。
  1766. * 状态栏点选 → 这里换新快照重载(自动分批到完成)+ 运行监测面板跟随。
  1767. */
  1768. watch(pointStatus, (next) => {
  1769. if (!lifecycleReady.value) return
  1770. monitoringStore.loadPanels({ pointStatus: next })
  1771. refreshMapForFilters({ pointStatus: next })
  1772. })
  1773. /**
  1774. * 票 37:设备类型筛选变化 → 运行监测面板与**一张图**都跟随。
  1775. *
  1776. * 三个端点(overview/distribution/situation)都收 `deviceType` 入参(services/monitoringApi.js
  1777. * 逐行读过),所以三块一起重取;`map-snapshot` 同样收(票 25 第 3 节冻结的路径)。
  1778. * 切模式不重置(deviceType 是 filterStore 的全局条件,与行业/片区同层)。
  1779. */
  1780. watch(deviceType, (next) => {
  1781. if (!lifecycleReady.value) return
  1782. monitoringStore.loadPanels({ deviceType: next })
  1783. refreshMapForFilters({ deviceType: next })
  1784. })
  1785. /** 把筛选条件下给地图并**换新快照重载**(票 08 的快照一致性:一次加载只对应一份数据)。 */
  1786. function refreshMapForFilters(next) {
  1787. mapStore
  1788. .applyFilters(next)
  1789. .then(() => detailStore.syncMapSnapshotSection())
  1790. }
  1791. onBeforeUnmount(() => {
  1792. if (clockTimer) clearInterval(clockTimer)
  1793. if (homeTimer) clearInterval(homeTimer)
  1794. // 票 41:锚定跟随的帧订阅与尺寸监听必须拆掉(与票 15/37 的"漏取消定时器"是同一个坑)。
  1795. if (stopMapFrame) {
  1796. stopMapFrame()
  1797. stopMapFrame = null
  1798. }
  1799. if (stopCameraState) {
  1800. stopCameraState()
  1801. stopCameraState = null
  1802. }
  1803. // 票 41:DEV 把手随外壳一起摘掉(与 mapSummary 把手同款)。
  1804. publishPointSelectionHandle(null)
  1805. // 票 43:设备把手一并摘掉。
  1806. publishDeviceSelectionHandle(null)
  1807. // 票 42:设备图层的四个监听一并拆掉(票 15/37 的"漏取消"是同一个坑)。
  1808. for (const stop of stopDeviceWatches) stop()
  1809. stopDeviceWatches.length = 0
  1810. deviceLayerStore.reset()
  1811. pipeEnhancementStore.clear()
  1812. enhancementDevicesStore.clear()
  1813. if (anchorResizeHandler) {
  1814. window.removeEventListener('resize', anchorResizeHandler)
  1815. anchorResizeHandler = null
  1816. }
  1817. lastAnchorKey = ''
  1818. // 票 37:闲置计时器与全局交互监听必须拆掉 —— 与票 15 的"漏取消定时器"是同一个坑。
  1819. if (idleTimer) clearInterval(idleTimer)
  1820. for (const domEvent of ['pointerdown', 'pointermove', 'wheel', 'keydown']) {
  1821. window.removeEventListener(domEvent, onAnyActivity, { capture: true })
  1822. }
  1823. setGlobeSpin(0)
  1824. // 票 15:连接是模块级单例,离开外壳必须停掉(否则重连定时器会跟着页面跑下去)。
  1825. runtimeStore.stop()
  1826. // 票 16:刷新周期的定时器同理必须停(票 15 刚在"漏取消定时器"上栽过一次)。
  1827. runtimeStore.stopFreshness()
  1828. // 票 38:定位反馈的自动收起定时器同理必须拆——离开外壳后它不该再去动 store 状态。
  1829. if (locateDismissTimer) {
  1830. window.clearTimeout(locateDismissTimer)
  1831. locateDismissTimer = null
  1832. }
  1833. })
  1834. // ---- 票 25:状态面板里的地图信息读数 ----
  1835. // 计数一律取自**同一份模式图层计划**(`modeLayerPlan`),不另开一份真相;
  1836. // 署名与底图措辞取自 mapService(与真正建图层用的是同一批计划)。
  1837. const mapMarkerCount = computed(() => modeLayerPlan.value.markers.length)
  1838. const mapMarkerStatusText = computed(() => {
  1839. const byStatus = modeLayerPlan.value.byStatus || {}
  1840. const keys = Object.keys(byStatus).sort()
  1841. return keys.length ? keys.map((key) => `${key} ${byStatus[key]}`).join(' · ') : '等待分页数据'
  1842. })
  1843. const mapCoordinateOverview = computed(() => mapStore.coordinate)
  1844. const mapBasemapLabel = computed(() => getBasemapLabel())
  1845. const mapDataSourceCredits = computed(() => getDataSourceCredits())
  1846. </script>
  1847. <style scoped>
  1848. /* 组件内自带的盒模型重置(原型图靠全局 `*` 重置,我们这里不依赖全局样式)。
  1849. 缺了它,铺满容器的外壳加上 padding 会撑出滚动条。 */
  1850. .cockpit-shell,
  1851. .cockpit-shell *,
  1852. .cockpit-shell *::before,
  1853. .cockpit-shell *::after {
  1854. box-sizing: border-box;
  1855. }
  1856. /* ============================================================
  1857. 骨架尺寸
  1858. 主题令牌(颜色 / 字号 / 间距 / 层级)集中在 styles/tokens.css,
  1859. 本文件不再声明;--u 是「设计像素」:1920 宽时为 1px,随视口线性放大、
  1860. 封顶 2px(4K 时正好一倍),小于 1920 时不再缩小。这样 4K 大屏不显空旷,
  1861. 同时页面仍是铺满而非等比留边。
  1862. ============================================================ */
  1863. /* 填满**所在容器**,而不是按 100vh 铺(票 28):宿主布局在顶部留了页头时(`npm run pre.dev`
  1864. 的 TvT 演示站是 54px 固定页头;将来 qiankun 主应用也可能套壳),100vh 会把外壳顶出容器
  1865. 底部——底部内容被裁、容器还多出一条纵向滚动条。改成 absolute inset:0 贴着最近的定位容器,
  1866. 容器多高就多高;窗口 resize 由 CSS 自己跟随,不需要挂载时量一次。 */
  1867. .cockpit-shell {
  1868. position: absolute;
  1869. inset: 0;
  1870. display: grid;
  1871. /* 顶栏 / 筛选条 / 主体:两条横栏压矮,把高度让给真正展示信息的主体 */
  1872. grid-template-rows: calc(52 * var(--u)) calc(34 * var(--u)) minmax(0, 1fr);
  1873. gap: calc(4 * var(--u));
  1874. overflow: hidden;
  1875. padding: calc(8 * var(--u)) calc(12 * var(--u)) calc(12 * var(--u));
  1876. color: var(--text);
  1877. font: var(--fs-body) / 1.45 var(--font);
  1878. }
  1879. /* 全局氛围底:径向辉光 + 细网格(原型 body::before / ::after 的等价实现)。
  1880. 用 absolute 而不是 fixed:随外壳自己的范围铺,宿主页头那一带不归驾驶舱画。 */
  1881. .cockpit-shell__atmosphere {
  1882. position: absolute;
  1883. inset: 0;
  1884. z-index: 0;
  1885. pointer-events: none;
  1886. background:
  1887. radial-gradient(1500px 760px at 50% -12%, rgba(0, 82, 168, 0.4), transparent 62%),
  1888. radial-gradient(1000px 620px at 4% 108%, rgba(0, 82, 168, 0.2), transparent 62%),
  1889. radial-gradient(1000px 620px at 96% 108%, rgba(0, 82, 168, 0.2), transparent 62%),
  1890. radial-gradient(760px 460px at 50% 46%, rgba(0, 61, 133, 0.3), transparent 70%),
  1891. linear-gradient(180deg, var(--bg-0, #010514) 0%, var(--bg-1, #02091b) 42%, var(--bg-0, #010514) 100%);
  1892. }
  1893. .cockpit-shell__atmosphere::after {
  1894. content: "";
  1895. position: absolute;
  1896. inset: 0;
  1897. opacity: 0.5;
  1898. background-image:
  1899. linear-gradient(rgba(1, 171, 234, 0.045) 1px, transparent 1px),
  1900. linear-gradient(90deg, rgba(1, 171, 234, 0.045) 1px, transparent 1px);
  1901. background-size: 56px 56px, 56px 56px;
  1902. mask-image: radial-gradient(120% 90% at 50% 30%, #000 30%, transparent 88%);
  1903. }
  1904. /* 只给真正的网格项加定位;不要用 `.cockpit-shell > *`,
  1905. 否则会以同权重覆盖 .cockpit-shell__atmosphere 的 position: fixed。 */
  1906. .cockpit-shell__topbar,
  1907. .cockpit-shell__scope,
  1908. .cockpit-shell__main {
  1909. position: relative;
  1910. z-index: 1;
  1911. min-width: 0;
  1912. }
  1913. /* ============================================================
  1914. 顶栏(压矮:56 设计像素)
  1915. ============================================================ */
  1916. /* 顶栏三列:品牌 / 一级模式 / 状态区。
  1917. 票 37:右侧状态区改成 `minmax(min-content, 1fr)`,**不再让它被挤到溢出**。
  1918. 原先写死 `minmax(240px, 1fr)`,`justify-content: flex-end` 在内容超宽时会**向左溢出**到
  1919. 模式按钮那一列上 —— 票 37 加了一个「返回全球」chip 后正好越界 46px,
  1920. 实测「归位」压住「预警处置」(x 855–897 vs 799–898)。
  1921. 改成按内容最小宽度兜底后,列宽随内容长,两个 chip 再多也不会压到模式区。 */
  1922. .cockpit-shell__topbar {
  1923. display: grid;
  1924. grid-template-columns: minmax(240px, 1fr) auto minmax(min-content, 1fr);
  1925. align-items: center;
  1926. gap: calc(12 * var(--u));
  1927. height: calc(52 * var(--u));
  1928. /* 票 17 第八轮 R2:基线(标题光带最低线 = path 最低线,即顶栏底)之上留 10u 保护区——
  1929. 内容实际可用高度 42u,按钮行(34u)在其中垂直居中,底边离基线 4u 出血,
  1930. **任何文字与按钮不得压到基线**。时钟 var(--num) 显式声明优先,不受继承影响。
  1931. 票 17 第九轮 R3:min-width: 0 —— 窄视口下让品牌列可收缩(标题切图 max-width 让位),
  1932. 避免 grid 把中列菜单挤到换行(按钮 nowrap 后溢出裁切)。 */
  1933. min-width: 0;
  1934. padding-bottom: calc(10 * var(--u));
  1935. font-family: var(--font-topbar);
  1936. }
  1937. .cockpit-shell__topbar-bg {
  1938. position: absolute;
  1939. inset: 0;
  1940. pointer-events: none;
  1941. background:
  1942. linear-gradient(180deg, rgba(0, 31, 92, 0.58) 0%, rgba(2, 9, 27, 0.12) 62%, transparent 100%),
  1943. radial-gradient(560px 92px at 50% 6%, rgba(1, 171, 234, 0.4), transparent 72%);
  1944. clip-path: polygon(0 0, 100% 0, 100% 74%, calc(100% - 46px) 74%, calc(100% - 62px) 100%, 62px 100%, 46px 74%, 0 74%);
  1945. }
  1946. /* 票 17 第十一轮:底边光路 = 裁切窗口。窗口从标题右侧(430u)铺到屏幕右缘、贴基线;
  1947. 背景把切图放大到 230% 窗口宽(画布列 22.9%–62% 的可见线段恰铺满窗口),
  1948. position 38.4% 100% = 线段左端对窗口左缘、亮线贴窗口底(=基线,与标题光带共线)。
  1949. 第九轮的 right-shift 方案失败原因:整图拉伸 100% 宽时线段按百分比映射视口,
  1950. 挪图片盒子改变不了线段落在标题上的事实。 */
  1951. /* 票 17 第十二轮:窗口左缘 430u → 340u——线段起点钻进标题光带尾部 ~90u,
  1952. 营造"光线从标题延伸出来"的衔接感;仍避开文字带(文字带右缘 ≈12%+75%×404 ≈ 315u)。 */
  1953. .cockpit-shell__topbar-path {
  1954. position: absolute;
  1955. left: calc(340 * var(--u));
  1956. right: 0;
  1957. bottom: 0;
  1958. height: calc(19 * var(--u));
  1959. background-image: var(--brand-path-url, none);
  1960. background-repeat: no-repeat;
  1961. background-size: 230% 100%;
  1962. background-position: 38.4% 100%;
  1963. pointer-events: none;
  1964. }
  1965. /* 票 17 第八轮 R2:两根背景装饰线挪出文字区——
  1966. 原来固定在 top:50% 且横跨 50%±400px,正好横穿菜单文字。
  1967. 现改为:垂直方向贴顶栏底部(top: 58%,在基线上方、不越过),水平方向
  1968. 一根贴最左(品牌区左侧空白)、一根贴最右(状态区右侧空白),不进文字带。 */
  1969. .cockpit-shell__wing {
  1970. position: absolute;
  1971. top: 58%;
  1972. width: calc(170 * var(--u));
  1973. height: 2px;
  1974. pointer-events: none;
  1975. background: linear-gradient(90deg, transparent, rgba(1, 171, 234, 0.75));
  1976. box-shadow: 0 0 12px rgba(1, 171, 234, 0.55);
  1977. opacity: 0.55;
  1978. }
  1979. .cockpit-shell__wing::after {
  1980. content: "";
  1981. position: absolute;
  1982. right: 0;
  1983. top: -3px;
  1984. width: 8px;
  1985. height: 8px;
  1986. background: var(--cyan);
  1987. transform: rotate(45deg);
  1988. box-shadow: 0 0 12px var(--cyan);
  1989. }
  1990. .cockpit-shell__wing.is-left { left: 0; }
  1991. .cockpit-shell__wing.is-right {
  1992. right: 0;
  1993. transform: scaleX(-1);
  1994. }
  1995. /* 票 17 第十一轮(结构性根因修复):品牌列**定高 + 绝对定位标题**。
  1996. 此前标题切图(高 98u)是 grid 项内容,把顶栏整行撑到 98u——菜单按钮"垂直居中"
  1997. 是相对 98u 行居中的,底边正好落在基线上(宽屏菜单"串在底线上"的真因;
  1998. 窄屏切图被 max-width 缩小、行变矮,菜单反而正常——与维护者观察完全一致)。
  1999. 现在:品牌列 height 52u + overflow visible,标题绝对定位溢出显示,
  2000. grid 行高恢复 52u,菜单/状态区真正在 42u 内容区里居中。 */
  2001. .cockpit-shell__brand {
  2002. position: relative;
  2003. height: calc(52 * var(--u));
  2004. min-width: 0;
  2005. padding-left: calc(6 * var(--u));
  2006. }
  2007. /* 标题切图(brand-title.png 940×228):绝对定位于品牌列、顶部对齐。
  2008. 宽 404u → 高 98u:亮线(画布 53.1%)= 顶栏底 52u,与底边光路共线;
  2009. 文字带 ≈53u 在亮线上。溢出顶栏的淡晕不占布局,由下方筛选条承接。
  2010. 窄视口让位:max-width 以品牌列宽为上限(min-width:0 生效后 = 可用宽),等比缩。 */
  2011. .cockpit-shell__mark {
  2012. position: absolute;
  2013. top: 0;
  2014. left: calc(6 * var(--u));
  2015. max-width: calc(100% - 12 * var(--u));
  2016. height: auto;
  2017. }
  2018. /* 票 18(批次 5):品牌标题后方光池(蓝湖 D6「光」层,设计 realFrame 489×128 @ (-19,-1),
  2019. 即相对品牌标题左上偏移 -19,-1——左缘超出标题 19/489 ≈ 3.9%,上缘超 1 设计像素)。
  2020. 复杂位图光效不可 CSS 无损复现(规格 03 判据),按设计 frame 铺在标题下层:
  2021. 锚点与标题切图同(left 6u),宽 = 标题 404u × 489/470 ≈ 1.0404 倍,
  2022. translateX(-3.885%) 抵消设计稿左偏(百分比对自身宽,即 -19/489)。
  2023. max-width 随标题的让位上限同步放大 1.0404 倍,窄视口一起缩。
  2024. 纯装饰(aria-hidden 层内的 ::before)、pointer-events:none,不参与拾取与命名;
  2025. manifest 缺条目时 --brand-pool-url 为 none,自动不画。 */
  2026. .cockpit-shell__brand::before {
  2027. content: '';
  2028. position: absolute;
  2029. top: calc(-1 * var(--u));
  2030. left: calc(6 * var(--u));
  2031. width: calc(404 * var(--u) * 1.0404);
  2032. max-width: calc((100% - 12 * var(--u)) * 1.0404);
  2033. aspect-ratio: 489 / 128;
  2034. transform: translateX(-3.885%);
  2035. background-image: var(--brand-pool-url, none);
  2036. background-repeat: no-repeat;
  2037. background-size: 100% 100%;
  2038. pointer-events: none;
  2039. }
  2040. /* 视觉隐藏但保留给读屏与走查断言(切图 aria-hidden,可访问名靠这里)。 */
  2041. .cockpit-shell__sr-only {
  2042. position: absolute;
  2043. width: 1px;
  2044. height: 1px;
  2045. overflow: hidden;
  2046. clip: rect(0 0 0 0);
  2047. white-space: nowrap;
  2048. }
  2049. .cockpit-shell__modes {
  2050. display: flex;
  2051. gap: calc(4 * var(--u));
  2052. justify-content: center;
  2053. position: relative;
  2054. }
  2055. .cockpit-shell__mode {
  2056. position: relative;
  2057. height: calc(34 * var(--u));
  2058. padding: 0 calc(17 * var(--u));
  2059. border: 0;
  2060. /* 票 17 第七轮(按蓝湖 D2 nav 取证重做):设计稿里菜单就是「纯文字 + 选中态底板」——
  2061. 未选中 #00b8ff 常规字重(PingFang 14px,Apple 系统字体走回退栈),选中白色 + Semibold,
  2062. 底板 = `nav/title-bg` 切图(顶部青色亮线 + 向下衰减浅蓝板,90×34@2x)。
  2063. 此前的切角梯形 + 深蓝渐变 + 下划线 + 无界黑均非设计稿所有,全部撤除。
  2064. 票 17 第九轮 R3:white-space: nowrap —— 窄视口下 grid 挤压中列会让文字换行、
  2065. 34u 定高裁掉第二行(实测只剩"总/基/运"单字);菜单文字永不换行,
  2066. 顶栏三列改为允许纵向压缩而不是挤压按钮。 */
  2067. color: var(--cyan-hi);
  2068. font-size: var(--fs-body, 14px);
  2069. white-space: nowrap;
  2070. background: transparent;
  2071. cursor: pointer;
  2072. transition: color 0.18s, filter 0.18s;
  2073. }
  2074. .cockpit-shell__mode:hover {
  2075. color: var(--text-strong);
  2076. filter: brightness(1.15);
  2077. }
  2078. .cockpit-shell__mode:focus-visible {
  2079. outline: 1px solid var(--cyan);
  2080. outline-offset: 2px;
  2081. }
  2082. .cockpit-shell__mode.is-active {
  2083. color: var(--text-strong);
  2084. font-weight: 600;
  2085. /* 底板切图经语义 key 注入(--nav-active-bg,见模板 header 绑定);素材缺失时退化为
  2086. 设计稿主蓝的极淡渐变,选中态仍可辨。 */
  2087. background-image: var(--nav-active-bg, none),
  2088. linear-gradient(180deg, rgba(1, 171, 234, 0.28), transparent);
  2089. background-repeat: no-repeat;
  2090. background-size: 100% 100%;
  2091. }
  2092. /* 票 17 第七轮:选中按钮下方的光晕(设计稿 D2 `光 x=906 y=49 w=221 h=35`——实测中心 x=1016
  2093. 正对选中按钮「预警处置」中心 x=1017,即**光带跟着选中按钮走**,不是挂在 nav 中间)。
  2094. 票 17 第八轮:光晕主体(亮线在图片 44%)随基线上移——bottom 对齐基线上方 4u,
  2095. 光晕亮线压在按钮文字底下而不越过基线(R2:随 10u 保护区微调,top 100%−8u)。 */
  2096. .cockpit-shell__mode.is-active::after {
  2097. content: "";
  2098. position: absolute;
  2099. top: calc(100% - 8 * var(--u));
  2100. left: 50%;
  2101. transform: translateX(-50%);
  2102. width: calc(221 * var(--u));
  2103. height: calc(35 * var(--u));
  2104. background-image: var(--nav-glow-url, none);
  2105. background-repeat: no-repeat;
  2106. background-position: center;
  2107. background-size: contain;
  2108. pointer-events: none;
  2109. }
  2110. .cockpit-shell__top-meta {
  2111. display: flex;
  2112. align-items: center;
  2113. justify-content: flex-end;
  2114. gap: calc(10 * var(--u));
  2115. padding-right: calc(8 * var(--u));
  2116. color: var(--text-2);
  2117. font-size: var(--fs-body, 14px);
  2118. white-space: nowrap;
  2119. }
  2120. .cockpit-shell__clock {
  2121. font-family: var(--num);
  2122. font-size: var(--fs-body, 14px);
  2123. letter-spacing: 0.06em;
  2124. color: var(--text-strong);
  2125. text-shadow: 0 0 12px rgba(1, 171, 234, 0.55);
  2126. }
  2127. .cockpit-shell__meta {
  2128. margin: 0;
  2129. color: var(--muted);
  2130. font-size: var(--fs-caption, 11.5px);
  2131. letter-spacing: 0.06em;
  2132. }
  2133. /* ============================================================
  2134. 全局筛选条(压矮:38 设计像素)
  2135. ============================================================ */
  2136. .cockpit-shell__scope {
  2137. z-index: 3;
  2138. display: flex;
  2139. align-items: center;
  2140. justify-content: space-between;
  2141. gap: calc(10 * var(--u));
  2142. overflow: visible;
  2143. padding: 0 calc(10 * var(--u));
  2144. border: 1px solid rgba(1, 171, 234, 0.24);
  2145. background: linear-gradient(180deg, rgba(0, 31, 92, 0.72), rgba(0, 15, 52, 0.88));
  2146. box-shadow: inset 0 1px 0 rgba(1, 171, 234, 0.1), var(--shadow);
  2147. /* 票 17:第二行筛选条与顶栏同属"顶部菜单栏",字体保持一致(系统黑体),
  2148. 否则两行按钮字体一细一粗更难看。此处全为中文标签,无 --num 数字依赖。 */
  2149. font-family: var(--font-topbar);
  2150. }
  2151. .cockpit-shell__scope-left,
  2152. .cockpit-shell__scope-right {
  2153. display: flex;
  2154. align-items: center;
  2155. gap: calc(6 * var(--u));
  2156. white-space: nowrap;
  2157. min-width: 0;
  2158. }
  2159. .cockpit-shell__scope-left {
  2160. flex: 1 1 auto;
  2161. overflow-x: auto;
  2162. overflow-y: hidden;
  2163. scrollbar-width: none;
  2164. }
  2165. .cockpit-shell__scope-left::-webkit-scrollbar {
  2166. display: none;
  2167. }
  2168. .cockpit-shell__scope-right {
  2169. flex: 0 1 auto;
  2170. justify-content: flex-end;
  2171. max-width: 46%;
  2172. }
  2173. /* 票 67:管线/构件的片区口径说明(状态详情里的一条 chip,允许换行、限宽)。 */
  2174. .cockpit-shell__pipe-scope {
  2175. display: inline-block;
  2176. max-width: 46ch;
  2177. white-space: normal;
  2178. line-height: 1.35;
  2179. text-align: left;
  2180. }
  2181. .cockpit-shell__scope-label {
  2182. color: var(--muted);
  2183. font-size: var(--fs-caption, 11.5px);
  2184. letter-spacing: 0.08em;
  2185. }
  2186. .cockpit-shell__chips {
  2187. display: flex;
  2188. gap: calc(4 * var(--u));
  2189. }
  2190. .cockpit-shell__divider {
  2191. width: 1px;
  2192. height: calc(16 * var(--u));
  2193. margin: 0 calc(3 * var(--u));
  2194. background: linear-gradient(180deg, transparent, rgba(1, 171, 234, 0.5), transparent);
  2195. }
  2196. /* 范围 / 行业按钮:作用于整个驾驶舱的全局条件(切模式不重置) */
  2197. .cockpit-shell__chip {
  2198. display: inline-flex;
  2199. align-items: center;
  2200. padding: calc(2 * var(--u)) calc(9 * var(--u));
  2201. border: 1px solid rgba(1, 171, 234, 0.32);
  2202. background: rgba(0, 20, 72, 0.6);
  2203. color: var(--text-2);
  2204. font: inherit;
  2205. /* 2026-10-12:走查反馈偏小,由 10.5 上调到 11.5。 */
  2206. font-size: var(--fs-caption, 11.5px);
  2207. letter-spacing: 0.04em;
  2208. white-space: nowrap;
  2209. clip-path: polygon(4px 0, 100% 0, 100% calc(100% - 4px), calc(100% - 4px) 100%, 0 100%, 0 4px);
  2210. cursor: pointer;
  2211. transition: all 0.16s;
  2212. }
  2213. /* 批次 1:行业筛选按钮的图标。用 margin 而非 gap —— 只有带图标的按钮才需要间距,
  2214. 不改通用 chip 的排版,避免影响范围/视图等其他同款按钮。 */
  2215. .cockpit-shell__industry-icon {
  2216. flex: none;
  2217. margin-right: calc(4 * var(--u));
  2218. text-shadow: none;
  2219. }
  2220. .cockpit-shell__chip:hover {
  2221. color: var(--text-strong);
  2222. border-color: rgba(1, 171, 234, 0.6);
  2223. background: rgba(0, 82, 168, 0.62);
  2224. }
  2225. .cockpit-shell__chip:focus-visible {
  2226. outline: 1px solid var(--cyan);
  2227. outline-offset: 2px;
  2228. }
  2229. .cockpit-shell__chip.is-active {
  2230. color: var(--text-strong);
  2231. border-color: rgba(0, 184, 255, 0.85);
  2232. background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
  2233. box-shadow: inset 0 0 16px rgba(1, 171, 234, 0.35), 0 0 14px rgba(1, 171, 234, 0.35);
  2234. text-shadow: 0 0 10px rgba(180, 240, 255, 0.9);
  2235. }
  2236. .cockpit-shell__scope-note {
  2237. color: var(--muted);
  2238. font-size: var(--fs-caption, 11.5px);
  2239. letter-spacing: 0.04em;
  2240. }
  2241. /* 票 08:地图摘要仍是状态详情的一行,收起时由上层摘要按钮替代其横向占位。 */
  2242. .cockpit-shell__map-summary {
  2243. display: flex;
  2244. align-items: center;
  2245. gap: calc(5 * var(--u));
  2246. min-width: 0;
  2247. overflow: hidden;
  2248. }
  2249. .cockpit-shell__status-summary {
  2250. position: relative;
  2251. flex: 0 1 auto;
  2252. min-width: min(220px, 28vw);
  2253. max-width: min(520px, 42vw);
  2254. }
  2255. .cockpit-shell__status-toggle {
  2256. display: flex;
  2257. align-items: center;
  2258. gap: calc(6 * var(--u));
  2259. width: 100%;
  2260. min-height: calc(28 * var(--u));
  2261. padding: calc(3 * var(--u)) calc(8 * var(--u));
  2262. border: 1px solid rgba(1, 171, 234, 0.32);
  2263. background: rgba(0, 20, 72, 0.72);
  2264. color: var(--text-2);
  2265. font: inherit;
  2266. font-size: var(--fs-caption, 11.5px);
  2267. text-align: left;
  2268. cursor: pointer;
  2269. clip-path: polygon(5px 0, 100% 0, 100% calc(100% - 5px), calc(100% - 5px) 100%, 0 100%, 0 5px);
  2270. }
  2271. .cockpit-shell__status-toggle:hover,
  2272. .cockpit-shell__status-toggle:focus-visible {
  2273. border-color: rgba(1, 171, 234, 0.75);
  2274. color: var(--text-strong);
  2275. outline: none;
  2276. }
  2277. .cockpit-shell__status-toggle [data-status-summary] {
  2278. flex: 1;
  2279. min-width: 0;
  2280. overflow: hidden;
  2281. text-overflow: ellipsis;
  2282. white-space: nowrap;
  2283. }
  2284. .cockpit-shell__status-dot {
  2285. width: calc(7 * var(--u));
  2286. height: calc(7 * var(--u));
  2287. flex: none;
  2288. border-radius: 50%;
  2289. background: var(--muted, #5680b0);
  2290. box-shadow: 0 0 8px currentColor;
  2291. }
  2292. .cockpit-shell__status-dot.is-ok {
  2293. background: var(--lv4, #27d17a);
  2294. color: var(--lv4, #27d17a);
  2295. }
  2296. .cockpit-shell__status-dot.is-warning {
  2297. background: var(--lv2, #ffb020);
  2298. color: var(--lv2, #ffb020);
  2299. }
  2300. .cockpit-shell__status-chevron {
  2301. color: var(--cyan, #01abea);
  2302. font-size: var(--fs-body, 14px);
  2303. line-height: 1;
  2304. }
  2305. .cockpit-shell__status-details {
  2306. position: absolute;
  2307. z-index: 20;
  2308. top: calc(100% + 5 * var(--u));
  2309. right: 0;
  2310. display: grid;
  2311. gap: calc(6 * var(--u));
  2312. width: min(560px, 48vw);
  2313. max-width: calc(100vw - 24 * var(--u));
  2314. max-height: min(62vh, 520px);
  2315. /* 2026-10-13:严格只竖向滚动(状态详情浮层同理)。 */
  2316. overflow-y: auto;
  2317. overflow-x: hidden;
  2318. padding: calc(9 * var(--u));
  2319. border: 1px solid rgba(1, 171, 234, 0.42);
  2320. background: rgba(1, 5, 20, 0.96);
  2321. box-shadow: 0 14px 32px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(130, 230, 255, 0.1);
  2322. }
  2323. .cockpit-shell__status-details > * {
  2324. min-width: 0;
  2325. }
  2326. .cockpit-shell__refresh-action {
  2327. flex: none;
  2328. min-width: calc(58 * var(--u));
  2329. justify-content: center;
  2330. gap: calc(5 * var(--u));
  2331. }
  2332. .cockpit-shell__refresh-glyph {
  2333. font-size: var(--fs-title, 15.5px);
  2334. line-height: 1;
  2335. }
  2336. /* 状态点:加载中呼吸、完成转绿、失败/过期转红 —— 颜色语义与小胶囊共存。 */
  2337. .cockpit-shell__map-summary-dot {
  2338. flex: none;
  2339. width: calc(6 * var(--u));
  2340. height: calc(6 * var(--u));
  2341. border-radius: 50%;
  2342. background: var(--lv2, #ffb020);
  2343. animation: map-summary-breathe 1.8s ease-in-out infinite;
  2344. }
  2345. .cockpit-shell__map-summary[data-map-summary-phase='complete'] .cockpit-shell__map-summary-dot {
  2346. background: var(--lv4, #27d17a);
  2347. animation: none;
  2348. }
  2349. .cockpit-shell__map-summary[data-map-summary-phase='failed'] .cockpit-shell__map-summary-dot,
  2350. .cockpit-shell__map-summary[data-map-summary-phase='expired'] .cockpit-shell__map-summary-dot {
  2351. background: var(--lv1, #ff3b52);
  2352. animation: none;
  2353. }
  2354. @keyframes map-summary-breathe {
  2355. 0%, 100% { opacity: 1; }
  2356. 50% { opacity: 0.3; }
  2357. }
  2358. .cockpit-shell__map-summary-text {
  2359. max-width: calc(150 * var(--u));
  2360. overflow: hidden;
  2361. color: var(--text-2);
  2362. font-size: var(--fs-caption, 11.5px);
  2363. letter-spacing: 0.03em;
  2364. text-overflow: ellipsis;
  2365. white-space: nowrap;
  2366. }
  2367. /* 加载中 / 快照失效时给状态词换色,让「还能不能继续取」一眼可辨。 */
  2368. .cockpit-shell__map-summary[data-map-summary-phase='expired'] .cockpit-shell__map-summary-text,
  2369. .cockpit-shell__map-summary[data-map-summary-phase='failed'] .cockpit-shell__map-summary-text {
  2370. color: var(--lv1);
  2371. }
  2372. .cockpit-shell__map-summary[data-map-summary-phase='complete'] .cockpit-shell__map-summary-text {
  2373. color: var(--lv4);
  2374. }
  2375. /* 票 05:共享分区状态板。横向排列、可换行;空间不够时先压这一块,不去挤筛选按钮。 */
  2376. .cockpit-shell__sections {
  2377. display: flex;
  2378. align-items: center;
  2379. gap: calc(4 * var(--u));
  2380. flex-wrap: nowrap;
  2381. /* 票 08:显式允许收缩——筛选条在 1280 宽时靠它和地图摘要块一起让位。 */
  2382. flex-shrink: 1;
  2383. min-width: 0;
  2384. overflow: hidden;
  2385. }
  2386. /* 票 17:定位失败反馈。用范围色(不是错误色)——「不在已加载范围内」是提示,不是故障。 */
  2387. .cockpit-shell__locate {
  2388. color: var(--lv2, #ffb020);
  2389. border-color: rgba(255, 176, 32, 0.5);
  2390. }
  2391. /*
  2392. 票 38(第 5 条):定位失败的可见反馈浮层。挂在 `<main>` 底部居中——
  2393. 两张面板栏之间、地图之上,不随「状态详情」的折叠而消失(那正是「点了没反应」的来源)。
  2394. pointer-events 只给关闭按钮以外的胶囊本体与边框(浮层不该挡地图操作)。
  2395. */
  2396. .cockpit-shell__notice {
  2397. position: absolute;
  2398. z-index: 6;
  2399. /*
  2400. * 票 48:本浮层与底部图例同处一张图的底部带,必须起算在图例**之上**,
  2401. * 否则图例贴底后会被这条反馈压住(或互相叠字)。偏移量读共享的
  2402. * --map-bottom-band,而不是在这里另写一个数——两处各写魔法数是漂移的根。
  2403. */
  2404. bottom: calc(var(--map-bottom-band) + 4 * var(--u));
  2405. left: 50%;
  2406. transform: translateX(-50%);
  2407. display: flex;
  2408. align-items: center;
  2409. gap: calc(6 * var(--u));
  2410. max-width: min(72%, calc(100% - 2 * var(--column-w)));
  2411. pointer-events: none;
  2412. }
  2413. .cockpit-shell__notice-chip {
  2414. min-width: 0;
  2415. padding: calc(4 * var(--u)) var(--space-2);
  2416. border: 1px solid rgba(255, 176, 32, 0.5);
  2417. border-radius: calc(8 * var(--u));
  2418. background: rgba(2, 10, 20, 0.88);
  2419. box-shadow: var(--shadow);
  2420. font-family: var(--font-topbar);
  2421. font-size: var(--fs-caption, 11.5px);
  2422. white-space: nowrap;
  2423. overflow: hidden;
  2424. text-overflow: ellipsis;
  2425. }
  2426. .cockpit-shell__notice-close {
  2427. pointer-events: auto;
  2428. padding: calc(3 * var(--u)) calc(8 * var(--u));
  2429. border: 1px solid rgba(1, 171, 234, 0.4);
  2430. border-radius: calc(8 * var(--u));
  2431. background: rgba(2, 10, 20, 0.88);
  2432. color: var(--text-2, #88b7e8);
  2433. font-family: var(--font-topbar);
  2434. font-size: var(--fs-caption, 11.5px);
  2435. cursor: pointer;
  2436. }
  2437. .cockpit-shell__notice-close:hover {
  2438. color: var(--text-strong);
  2439. border-color: rgba(1, 171, 234, 0.7);
  2440. }
  2441. /* 票 15:状态推送连接状态。连上时是常态色(正常运行),未连上/鉴权失败转告警色。 */
  2442. .cockpit-shell__ws {
  2443. color: var(--muted, #5680b0);
  2444. }
  2445. /* 票 38:自建雾读数。生效/待命用常态色;不可用或降级转注意色(**不使用错误色** ——
  2446. 雾不可用属"能力降级、业务照常",不是故障;与票 05 的分区状态同一条立场)。 */
  2447. .cockpit-shell__fog {
  2448. color: var(--muted, #5680b0);
  2449. }
  2450. .cockpit-shell__fog[data-fog-state='ready'][data-fog-phase='locked'] {
  2451. color: var(--lv3, #01abea);
  2452. border-color: rgba(1, 171, 234, 0.4);
  2453. }
  2454. .cockpit-shell__fog[data-fog-degrade='reduced'],
  2455. .cockpit-shell__fog[data-fog-degrade='off'],
  2456. .cockpit-shell__fog.is-bad {
  2457. color: var(--lv2, #ffb020);
  2458. border-color: rgba(255, 176, 32, 0.45);
  2459. }
  2460. .cockpit-shell__ws[data-ws-state='open'] {
  2461. color: var(--lv4, #27d17a);
  2462. border-color: rgba(53, 227, 154, 0.4);
  2463. }
  2464. .cockpit-shell__ws.is-bad {
  2465. color: var(--lv1, #ff3b52);
  2466. border-color: rgba(255, 59, 82, 0.45);
  2467. }
  2468. /* 票 16:新鲜度。新鲜=常态色;**过期必须显眼**(保留旧值但不能被当成实时);不可用=告警色。 */
  2469. .cockpit-shell__freshness[data-freshness='current'] {
  2470. color: var(--lv4, #27d17a);
  2471. }
  2472. .cockpit-shell__freshness[data-freshness='empty'] {
  2473. color: var(--muted, #5680b0);
  2474. }
  2475. .cockpit-shell__freshness.is-stale {
  2476. color: var(--lv2, #ffb020);
  2477. border-color: rgba(255, 176, 32, 0.55);
  2478. }
  2479. .cockpit-shell__freshness.is-bad {
  2480. color: var(--lv1, #ff3b52);
  2481. border-color: rgba(255, 59, 82, 0.45);
  2482. }
  2483. /* 「部分失败」的补充说明:同一枚胶囊里再补一句,不另起一块浮层压住一张图。 */
  2484. .cockpit-shell__freshness-extra {
  2485. color: var(--lv2, #ffb020);
  2486. font-weight: 700;
  2487. }
  2488. /* ============================================================
  2489. 主体:一张图铺满背景,左右两栏浮在图上
  2490. ============================================================ */
  2491. .cockpit-shell__main {
  2492. position: relative;
  2493. min-height: 0;
  2494. }
  2495. .cockpit-shell__map {
  2496. position: absolute;
  2497. inset: 0;
  2498. z-index: var(--z-map);
  2499. }
  2500. /* 局部增强允许覆盖一张图,保持外壳交互层高于两侧业务栏。 */
  2501. .cockpit-shell__main > .cockpit-shell__enhancement {
  2502. width: min(1100px, calc(100% - 32px));
  2503. height: min(760px, calc(100% - 32px));
  2504. z-index: calc(var(--z-columns) + 1);
  2505. pointer-events: auto;
  2506. }
  2507. .cockpit-shell__column {
  2508. position: absolute;
  2509. top: 0;
  2510. bottom: 0;
  2511. z-index: var(--z-columns);
  2512. display: flex;
  2513. flex-direction: column;
  2514. gap: calc(8 * var(--u));
  2515. /* 栏宽是共享的布局尺寸:两级详情浮层要贴着它让位(见 styles/tokens.css 的 --column-w)。 */
  2516. width: var(--column-w);
  2517. min-height: 0;
  2518. pointer-events: none;
  2519. }
  2520. .cockpit-shell__column > * {
  2521. pointer-events: auto;
  2522. }
  2523. .cockpit-shell__column.is-left { left: 0; }
  2524. .cockpit-shell__column.is-right { right: 0; }
  2525. /* 票 10:两级详情浮层。贴在两栏之间的空档里、右栏左侧,
  2526. 宽度与栏同量级;高度由内容决定、超长时卡片自己滚动。
  2527. 2026-10-13 维护者反馈设备详情面板太窄:340→380u(上限 420→460px)。 */
  2528. .cockpit-shell__detail {
  2529. position: absolute;
  2530. z-index: var(--z-columns);
  2531. top: 0;
  2532. right: calc(var(--column-w) + var(--space-2));
  2533. width: min(calc(100% - 24px), clamp(420px, calc(480 * var(--u)), 560px));
  2534. max-height: 100%;
  2535. min-height: 0;
  2536. display: flex;
  2537. flex-direction: column;
  2538. pointer-events: auto;
  2539. }
  2540. /* 票 41:锚定形态。层级**低于**四个详情窗(R-3:允许被侧栏遮盖),
  2541. 只避开画布边缘、允许被画布裁切(R-2),唯一隐藏条件是窗口完全离开画面(R-4)。 */
  2542. .cockpit-shell__detail.is-anchored {
  2543. z-index: 1;
  2544. right: auto;
  2545. /* 内容不足时按内容高(不留空白);超长时由 JS 钉住高度、卡内滚动接管。 */
  2546. max-height: min(80%, 100%);
  2547. /* 允许被画布边缘裁切:不因此整体消失(规格 R-2)。 */
  2548. overflow: hidden;
  2549. border: 1px solid var(--panel-line-hi, #01abea);
  2550. /* 2026-10-23:背景与磨砂归**内层卡片**(.point-detail)单层持有 ——
  2551. 外层再叠一份渐变会双层相加(有效不透明度 ≈0.96),比两侧面板实得多;
  2552. 去掉后锚定/固定两态与两侧面板完全同款(半透明 token 背景 + 16px 磨砂)。 */
  2553. box-shadow: var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
  2554. }
  2555. /* 归属短线:从图标外缘指向卡片,`transform-origin` 在左端,长度由 JS 给。 */
  2556. .cockpit-shell__anchor-lead {
  2557. position: absolute;
  2558. z-index: 1;
  2559. height: 1px;
  2560. transform-origin: 0 50%;
  2561. background: linear-gradient(90deg, rgba(1, 171, 234, 0.85), rgba(1, 171, 234, 0.25));
  2562. box-shadow: 0 0 6px rgba(1, 171, 234, 0.5);
  2563. pointer-events: none;
  2564. }
  2565. /* 暂隐时的顶栏入口:与其它 chip 同形,动作按钮收在文字后面。 */
  2566. .cockpit-shell__anchor-status {
  2567. color: var(--lv2, #ffb020);
  2568. border-color: rgba(255, 176, 32, 0.5);
  2569. display: inline-flex;
  2570. align-items: center;
  2571. gap: calc(4 * var(--u));
  2572. }
  2573. .cockpit-shell__anchor-status button {
  2574. padding: 0 calc(4 * var(--u));
  2575. border: 1px solid rgba(255, 176, 32, 0.5);
  2576. background: rgba(0, 20, 72, 0.6);
  2577. color: inherit;
  2578. font: inherit;
  2579. cursor: pointer;
  2580. }
  2581. .cockpit-shell__anchor-status button:hover,
  2582. .cockpit-shell__anchor-status button:focus-visible {
  2583. color: var(--text-strong);
  2584. border-color: rgba(255, 176, 32, 0.9);
  2585. outline: none;
  2586. }
  2587. .cockpit-shell__event-detail {
  2588. position: absolute;
  2589. z-index: 5;
  2590. top: 24px;
  2591. left: calc(var(--column-w) + 24 * var(--u));
  2592. width: min(480px, calc(100% - var(--column-w) - 48 * var(--u)));
  2593. max-height: calc(100% - 48px);
  2594. overflow-y: auto;
  2595. overflow-x: hidden;
  2596. border: 1px solid var(--cyan);
  2597. background: linear-gradient(var(--panel-top), var(--panel-bot)), var(--bg-0);
  2598. box-shadow: var(--shadow);
  2599. }
  2600. </style>