CockpitShell.vue 123 KB

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