CockpitShell.vue 151 KB

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