CockpitShell.vue 171 KB

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