| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632 |
- <!--
- 驾驶舱外壳:唯一 /cockpit 路由的全部内容。
- 骨架与视觉参考《驾驶舱原型图-美化版》(切角面板 / 青色辉光 / 深空蓝底 / 全息地图壳),
- 但不锁 16:9:页面铺满视口,**中央一张图作为整个主体的背景铺满**,左右两栏浮在图上。
- 语义按 ADR 0003 / 0007:四个一级模式、模式内 4 个二级面板同屏展示、无子视图切换。
- 一级模式只通过 uiStore.mode 切换,不新增路由、不重建外壳与地图节点。
- 范围与行业是全局筛选(filterStore),切换一级模式不得重置它们(验收第 ⑦ 条)。
- -->
- <template>
- <div
- class="cockpit-shell"
- :data-font-gate="fontGate"
- :data-font-faces="fontFaceCount"
- >
- <div class="cockpit-shell__atmosphere" aria-hidden="true"></div>
- <header class="cockpit-shell__topbar" :style="navDecorStyle">
- <div class="cockpit-shell__topbar-bg" aria-hidden="true"></div>
- <!-- 票 17 第十一轮:底边光路改为**裁切窗口**——只显示切图中段线段(画布列 22.9%–62%),
- 从标题右侧起铺到屏幕右缘。此前整图拉伸 100% 宽,线段按百分比映射视口,
- 起点永远落在标题上(挪图片盒子无济于事)。URL 经语义 key 注入(navDecorStyle)。 -->
- <div class="cockpit-shell__topbar-path" aria-hidden="true"></div>
- <span class="cockpit-shell__wing is-left" aria-hidden="true"></span>
- <span class="cockpit-shell__wing is-right" aria-hidden="true"></span>
- <div class="cockpit-shell__brand">
- <!--
- 票 17 第六轮(维护者:要符合切图素材风格,把 brand-glow / brand-glow-path 用上):
- 标题回到蓝湖切图 `Group 5061`(渐变文字 + 底座光带,设计原样)。
- 第五轮"太小"的真因是**按顶栏高度等比缩放**——切图 470×114 里文字只占上部 53.5%,
- 等比进 52u 顶栏后文字仅 29u。现在**按文字高度反推整图宽**(宽 313u → 高 76u → 文字 ≈41u),
- 图片顶部对齐(不居中,切图文字从画布第 0 行开始,居中会裁掉字顶),
- 底部淡晕溢出顶栏、由下方筛选条自然承接——正是设计稿里光层(114)比文字(61)高的关系。
- -->
- <CockpitIcon class="cockpit-shell__mark" name="brand" size="calc(404 * var(--u))" />
- <strong class="cockpit-shell__sr-only">城市生命线驾驶舱系统</strong>
- </div>
- <nav class="cockpit-shell__modes" aria-label="一级模式">
- <button
- v-for="item in MODES"
- :key="item"
- type="button"
- class="cockpit-shell__mode"
- :class="{ 'is-active': item === mode }"
- :aria-pressed="item === mode"
- @click="uiStore.setMode(item)"
- >
- {{ modeLabel(item) }}
- </button>
- </nav>
- <div class="cockpit-shell__top-meta">
- <!--
- 票 30:快速归位(回到目标城市预设视角)。
- 票 37:区内/区外文案不同 —— 区内是「归位」(已进区,只是回该视角);
- 区外是「归位/进入」(点它就进区并锁定,ADR 0009 R2-5)。
- -->
- <button
- type="button"
- class="cockpit-shell__chip cockpit-shell__home"
- :class="{ 'is-active': atHomeView }"
- :data-home-at="atHomeView ? 'yes' : 'no'"
- :data-area-locked="areaLocked ? 'yes' : 'no'"
- :title="homeTitle"
- :aria-label="areaBar.homeLabel"
- @click="returnHome()"
- >
- {{ areaBar.homeLabel }}
- </button>
- <!-- 票 37:只有区内才有「返回全球」(区外它没有意义,不占位置)。 -->
- <button
- v-if="areaBar.returnGlobal"
- type="button"
- class="cockpit-shell__chip cockpit-shell__return-global"
- data-return-global
- :title="`解除主场景区限位,回到自由全球视角(区内最远 ${Math.round(areaLimits.max)} m)`"
- @click="returnToGlobal()"
- >
- 返回全球
- </button>
- <!-- 票 04:配置三态 + 数据源,顶栏可演示;data-* 供走查断言。
- 票 32:这是开发期的诊断读数,生产形态不占顶栏(状态退到底部小字)。 -->
- <span
- v-if="configLoaded && uiDebug"
- class="cockpit-shell__config"
- :data-config-state="configView.state"
- :data-data-source="configView.dataSource"
- :data-source="configState.source"
- >
- <StatusTag :tone="configView.tone" :label="configView.label" />
- </span>
- <span class="cockpit-shell__chip">只读视图</span>
- <span class="cockpit-shell__clock">{{ clockText }}</span>
- <p v-if="uiDebug" class="cockpit-shell__meta">当前模式 {{ mode }}</p>
- </div>
- </header>
- <!-- 全局筛选:作用于整个驾驶舱,切换一级模式不重置 -->
- <div class="cockpit-shell__scope">
- <div class="cockpit-shell__scope-left">
- <span class="cockpit-shell__scope-label">当前范围</span>
- <div class="cockpit-shell__chips">
- <button
- v-for="item in SCOPES"
- :key="item.key"
- type="button"
- class="cockpit-shell__chip cockpit-shell__scope-btn"
- :class="{ 'is-active': item.key === scope }"
- :aria-pressed="item.key === scope"
- :data-scope="item.key"
- @click="filterStore.setScope(item.key)"
- >
- {{ item.label }}
- </button>
- </div>
- <span class="cockpit-shell__divider" aria-hidden="true"></span>
- <span class="cockpit-shell__scope-label">行业</span>
- <div class="cockpit-shell__chips">
- <button
- v-for="item in industryChips"
- :key="item.key"
- type="button"
- class="cockpit-shell__chip cockpit-shell__industry-btn"
- :class="{ 'is-active': item.key === industry }"
- :aria-pressed="item.key === industry"
- :data-industry="item.key"
- @click="filterStore.setIndustry(item.key)"
- >
- <!-- 批次 1:行业图标,纯装饰;aria-hidden 不参与按钮命名。 -->
- <CockpitIcon v-if="item.icon" :name="item.icon" :size="industryIconSize" class="cockpit-shell__industry-icon" />
- {{ item.label }}
- </button>
- </div>
- <span class="cockpit-shell__divider" aria-hidden="true"></span>
- <!--
- 票 21:地面/管线视图(决策地图票 05 定的「全局视图条件」)。
- 它是全局条件,与范围/行业同级 —— 切换一级模式不重置它。
- -->
- <span class="cockpit-shell__scope-label">视图</span>
- <div class="cockpit-shell__chips">
- <button
- v-for="item in VIEW_MODE_OPTIONS"
- :key="item.key"
- type="button"
- class="cockpit-shell__chip cockpit-shell__view-btn"
- :class="{ 'is-active': item.key === viewMode }"
- :aria-pressed="item.key === viewMode"
- :data-view-mode="item.key"
- :title="item.hint"
- @click="switchViewMode(item.key)"
- >
- {{ item.label }}
- </button>
- </div>
- </div>
- <div class="cockpit-shell__scope-right">
- <div
- class="cockpit-shell__status-summary"
- :class="{ 'is-open': statusPanelExpanded }"
- :data-status-expanded="statusPanelExpanded ? 'true' : 'false'"
- >
- <button
- type="button"
- class="cockpit-shell__status-toggle"
- data-status-toggle
- :aria-expanded="statusPanelExpanded"
- aria-controls="cockpit-status-details"
- @click="statusPanelExpanded = !statusPanelExpanded"
- >
- <i class="cockpit-shell__status-dot" :class="statusSummaryTone" aria-hidden="true"></i>
- <span data-status-summary>{{ statusSummaryText }}</span>
- <span class="cockpit-shell__status-chevron" aria-hidden="true">{{ statusPanelExpanded ? '⌃' : '⌄' }}</span>
- </button>
- <div
- id="cockpit-status-details"
- v-show="statusPanelExpanded"
- class="cockpit-shell__status-details"
- data-status-details
- >
- <!--
- 票 14(决策 02 的 Q24/Q25):地图摘要进入系统/筛选变化后**自动分批加载到完成**。
- 它作为状态详情的一行保留 `data-map-summary-*` 断言面;手动刷新按钮移到状态摘要
- 右侧独立位置,避免和范围/行业/视图选择争抢横向空间。
- -->
- <div
- class="cockpit-shell__map-summary"
- :data-map-summary-phase="mapSummaryView.phase"
- role="status"
- aria-label="地图摘要"
- >
- <i class="cockpit-shell__map-summary-dot" aria-hidden="true"></i>
- <span class="cockpit-shell__map-summary-text" data-map-summary-label :title="mapSummaryView.detail">
- {{ mapSummaryView.label }}
- </span>
- </div>
- <!--
- 票 05:共享数据分区的状态板。验收第 6 条要的「只把受影响分区标为数据不可用、
- 保留已成功分区与地图骨架」在这里可见;每个分区带 data-section-* 供走查断言。
- 票 14(规格 09):real 与 mock **同构启动**——同一套分区语义对两种数据源都成立,
- real 的失败路径(不可达 / 未配地址)同样只标对应分区。
- -->
- <span class="cockpit-shell__scope-label">数据分区</span>
- <div class="cockpit-shell__sections" data-section-board>
- <SectionStateTag
- v-for="item in sharedSections"
- :key="item.name"
- :name="item.name"
- :section="item.section"
- :label="item.label"
- />
- </div>
- <!--
- 票 25:地图信息并入**本状态面板**,不再另起容器。
- 维护者反馈过另起面板会与顶部图例、本面板互相占用容器(图一 vs 图二)。
- 内容含:当前模式、降级说明、图层、标记计数、坐标准入明细、引擎读数、
- 数据来源署名(Cesium 自带署名已移出画面,见 cesiumEngine.js)。
- -->
- <MapInfoPanel
- class="cockpit-shell__map-info"
- :mode-label="modeLabel(mode)"
- :ui-debug="uiDebug"
- :degraded-notes="modeLayerPlan.degradedNotes"
- :marker-on-map="mapMarkerCount"
- :marker-status-text="mapMarkerStatusText"
- :coordinate-overview="mapCoordinateOverview"
- :basemap-label="mapBasemapLabel"
- :credits="mapDataSourceCredits"
- />
- <!--
- 票 17:定位反馈。**票 38(第 5 条)把它从这块折叠详情里挪走了**——
- 「状态详情」默认收起,失败提示藏在里面等于「点了没反应」;
- 现在它是主体区底部的浮层(见 cockpit-shell__notice),这里只留注释锚点。
- -->
- <span v-if="detailOpen && !introVisible && !fixedLocationReason && !anchorLayout.visible"
- class="cockpit-shell__chip cockpit-shell__anchor-status" data-anchor-hidden
- :data-anchor-reason="anchorLayout.reason" role="status">
- {{ selectedDetailCode }} · {{ hiddenAnchorText }}
- <button v-if="canRelocate" type="button" data-anchor-relocate @click="relocateSelected">重新定位</button>
- <button type="button" data-anchor-close @click="detailStore.closeDetail()">关闭</button>
- </span>
- <!--
- 票 42(规格 14:刷新核对链):换新快照期间设备图层已挂起,详情进入「待核对」;
- 顶栏如实提示并保留关闭入口(关闭按钮在上方 data-anchor-hidden chip 里)。
- -->
- <span v-if="snapshotPending && detailOpen"
- class="cockpit-shell__chip cockpit-shell__anchor-status" data-snapshot-pending role="status">
- 快照核对中 · 关联设备图层已暂撤
- </span>
- <!--
- 票 42:完整加载仍找不到所选点位 → 详情已按规格关闭,并说明原因。
- -->
- <span v-if="snapshotAbsentNote" class="cockpit-shell__chip cockpit-shell__locate" data-snapshot-absent role="status">
- {{ snapshotAbsentNote }}
- </span>
- <!--
- 票 37:进区受阻时的如实提示(主场景区未配置 / 配置非法)。
- 与票 04/05 同一条立场:能力不可用只标出来,不弹窗、不遮罩、不挡业务。
- -->
- <span v-if="areaNotice" class="cockpit-shell__chip cockpit-shell__locate" data-area-notice role="status">
- {{ areaNotice }}
- </span>
- <!--
- 票 15:状态推送的连接状态(验收第 6 条「连接/重连/过期/失败可演示」)。
- data-ws-state 是走查断言面;文案来自运行时服务,外壳不重写。
- -->
- <span
- class="cockpit-shell__chip cockpit-shell__ws"
- :class="{ 'is-bad': !runtimeStore.live }"
- :data-ws-state="runtimeStore.status.state"
- :title="runtimeStore.status.lastError || runtimeStore.statusText"
- role="status"
- >
- {{ runtimeStore.statusText }}
- <span v-if="runtimeStore.orderScanIntervalMs" data-order-scan-delay> · 工单扫描间隔 {{ runtimeStore.orderScanIntervalMs / 1000 }} 秒</span>
- </span>
- <!--
- 票 16:统一新鲜度 —— 「最近一次成功更新」+ 四态。
- 过期时**必须**看得见(验收第 4 条:保留旧值但不伪装成实时),
- 所以文案与色键都来自 domain/freshnessModel.js,外壳不重写。
- data-freshness / data-freshness-at 是走查断言面。
- -->
- <span
- class="cockpit-shell__chip cockpit-shell__freshness"
- :class="{ 'is-stale': runtimeStore.retained, 'is-bad': runtimeStore.freshness.view === 'unavailable' }"
- :data-freshness="runtimeStore.freshness.view"
- :data-freshness-at="runtimeStore.freshness.lastSuccessClock"
- :data-freshness-overall="overallFreshnessState"
- :title="runtimeStore.freshness.reason || runtimeStore.freshness.text"
- role="status"
- >
- 更新 {{ runtimeStore.freshness.lastSuccessClock }} · {{ runtimeStore.freshness.text }}
- <!--
- 票 16 验收第 2 条:**部分失败**是跨分区的性质,单块面板只会说自己那一份,
- 所以在这里补一句整体结论(判据来自 domain/freshnessModel.js,界面不数数)。
- -->
- <b v-if="overallFreshnessState === 'partial'" class="cockpit-shell__freshness-extra" data-freshness-partial>
- · {{ overallFreshnessLabel }}
- </b>
- </span>
- </div>
- </div>
- </div>
- <button
- type="button"
- class="cockpit-shell__chip cockpit-shell__refresh-action"
- data-map-summary-action
- data-map-refresh
- :disabled="mapStore.busy"
- aria-label="手动刷新快照"
- title="换到后端最新快照,并自动从第 1 页加载到完成"
- @click="refreshMapSnapshot"
- >
- <span aria-hidden="true" class="cockpit-shell__refresh-glyph">↻</span>
- </button>
- </div>
- <main class="cockpit-shell__main">
- <!--
- 中央一张图:铺满整个主体背景。常驻——不放在 v-for 内,也不加 v-if/key,
- 一级模式切换只重渲染左右两栏,本节点保持原实例。
- -->
- <section class="cockpit-shell__map" aria-label="中央一张图">
- <!--
- 票 46:模式图层计划在**外壳**算一次(外壳握有设备行/异常设备/选择态全部输入),
- 地图组件只消费计划 —— 模式过滤在 `domain/modeLayerPlan.js` 收口,无第二次过滤。
- 引导态(`introVisible`)时计划输出全空:无标记、无图例、无计数浮标、无详情;
- 地图摘要仍可预取(mapStore 照常加载)。
- -->
- <MapCanvas
- :mode-label="modeLabel(mode)"
- :ui-debug="uiDebug"
- :plan="modeLayerPlan"
- @pipeline-pick="selectPipeEnhancement"
- />
- <PipeEnhancement :plan="pipeEnhancementPlan" @close="closePipeEnhancement" />
- </section>
- <aside class="cockpit-shell__column is-left" aria-label="左栏">
- <CockpitPanel
- v-for="panelId in columns.left"
- :key="panelId"
- :panel-id="panelId"
- :title="labelForPanel(panelId)"
- :meta="panelMeta(panelId)"
- :section="sectionOf(panelId)"
- :expanded="isExpanded(panelId)"
- @toggle="uiStore.togglePanel(panelId)"
- >
- <PanelBlocks
- v-if="hasPanelView(panelId) && !['currentEvents', 'historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)"
- :view="panelViewOf(panelId)"
- @locate="locateOnMap"
- @open-card="openLedgerCard"
- @ledger-more="loadMoreLedger"
- @badge-needed="loadLedgerBadge"
- @panel-action="handlePanelAction"
- />
- <!--
- 票 13:当前预警面板挂**事件列表 + 预警/报警切换**(可复用组件)。
- 它是自己的组件而不是 PanelBlocks 的块:事件列表的形状(分段切换 +
- 表格行)不在 kpi/bars/rows/list 四种块画法里,硬塞进共享渲染层会
- 让 PanelBlocks 认识事件字段(票 18 解除的耦合又长回来)。
- 票 19 的另外三块面板复用它(同一个 eventStore.mode 切换状态)。
- -->
- <EventListPane v-else-if="panelId === 'currentEvents'" />
- <WarningResponsePane v-else-if="['historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)" :panel-id="panelId" />
- <PanelPlaceholder v-else :panel-id="panelId" />
- </CockpitPanel>
- </aside>
- <aside class="cockpit-shell__column is-right" aria-label="右栏">
- <CockpitPanel
- v-for="panelId in columns.right"
- :key="panelId"
- :panel-id="panelId"
- :title="labelForPanel(panelId)"
- :meta="panelMeta(panelId)"
- :section="sectionOf(panelId)"
- :expanded="isExpanded(panelId)"
- @toggle="uiStore.togglePanel(panelId)"
- >
- <PanelBlocks
- v-if="hasPanelView(panelId) && !['currentEvents', 'historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)"
- :view="panelViewOf(panelId)"
- @locate="locateOnMap"
- @open-card="openLedgerCard"
- @ledger-more="loadMoreLedger"
- @badge-needed="loadLedgerBadge"
- @panel-action="handlePanelAction"
- />
- <!--
- 票 13:当前预警面板挂**事件列表 + 预警/报警切换**(可复用组件)。
- 它是自己的组件而不是 PanelBlocks 的块:事件列表的形状(分段切换 +
- 表格行)不在 kpi/bars/rows/list 四种块画法里,硬塞进共享渲染层会
- 让 PanelBlocks 认识事件字段(票 18 解除的耦合又长回来)。
- 票 19 的另外三块面板复用它(同一个 eventStore.mode 切换状态)。
- -->
- <EventListPane v-else-if="panelId === 'currentEvents'" />
- <WarningResponsePane v-else-if="['historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)" :panel-id="panelId" />
- <PanelPlaceholder v-else :panel-id="panelId" />
- </CockpitPanel>
- </aside>
- <!--
- 票 10:两级详情浮层(点地图上的监测点打开)。浮在图上、两栏之间;
- 它是 `<main>` 的兄弟而不是地图节点的子节点 —— MapCanvas 仍是常驻的那个实例。
- 位置与宽度由本外壳给(组件自己不假设身处哪里,票 18 的台账面板可复用同一个卡片)。
- 票 46:**选择态暂停**(规格 18)—— 目标模式不显示所选项时详情暂停(隐藏但保留
- 选择身份),切回且对象恢复可见时自动恢复;引导态同样不显示。
- -->
- <div v-if="detailOpen && !introVisible && selectionVisibleInMode && anchorLayout.visible && !fixedLocationReason"
- class="cockpit-shell__anchor-lead" data-anchor-lead :style="anchorLeadStyle" aria-hidden="true"></div>
- <aside v-if="detailOpen && !introVisible && selectionVisibleInMode" ref="detailRef" class="cockpit-shell__detail"
- :class="{ 'is-anchored': !fixedLocationReason }"
- :style="detailStyle"
- :data-anchor-state="fixedLocationReason ? 'fixed' : anchorLayout.visible ? 'visible' : 'hidden'"
- :data-anchor-side="anchorLayout.side"
- :data-anchor-code="selectedDetailCode"
- :data-selection-paused="selectionVisibleInMode ? 'no' : 'yes'"
- aria-label="点位与设备详情">
- <PointDetailCard :location-reason="fixedLocationReason" />
- </aside>
- <aside v-if="eventStore.selectedEvent && uiStore.mode === 'warningResponse' && !introVisible"
- class="cockpit-shell__event-detail" aria-label="事件详情" @keydown.esc="eventStore.closeEvent()">
- <EventDetailCard />
- </aside>
- <!--
- 票 38(第 5 条):定位反馈浮层。原来它挂在顶部「状态详情」里,而那块**默认收起**——
- 点「定位」后失败提示永远看不见,维护者读作「点了没反应」。「没反应」是缺陷,
- 所以三种失败(不在已加载范围 / 后端判定不可上图 / 地图未就绪)必须**当场可见**:
- 居中底部浮层,自动收起。成功仍不给文字(地图真的会动、详情卡会开,再弹一句是噪音)。
- data-locate-result 断言面随它搬到这里,走查仍按该属性取数。
- -->
- <div v-if="locateFeedback" class="cockpit-shell__notice" data-locate-notice>
- <span
- class="cockpit-shell__notice-chip cockpit-shell__locate"
- :data-locate-result="locateFeedback.result"
- role="status"
- >
- {{ locateFeedback.pointCode }} · {{ locateFeedback.reasonText }}
- </span>
- <button type="button" class="cockpit-shell__notice-close" data-locate-close @click="dismissLocateFeedback">关闭</button>
- </div>
- </main>
- <!-- 进入引导(票 30):覆盖全屏,点击进入后飞到目标城市预设视角 -->
- <CockpitIntro
- v-if="intro.enabled && introVisible"
- :title="intro.title"
- :subtitle="intro.subtitle"
- :spin-seconds="intro.spinSeconds"
- @enter="enterSystem"
- />
- </div>
- </template>
- <script setup>
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
- import { storeToRefs } from 'pinia'
- import { MODES, columnsForMode, labelForPanel, modeLabel } from './domain/panelCatalog.js'
- import { panelView, hasPanelView } from './domain/panelViews.js'
- // 票 20:概览的联动映射(`link` 键 → 目标模式);外壳不自己发明这张表。
- import { OVERVIEW_LINK_MODES } from './domain/overviewPanels.js'
- import { LOCATE_RESULT } from './domain/mapLocate.js'
- import { resolveDetailAnchor, detailAnchorLocation } from './domain/detailAnchor.js'
- import { DEVICE_LAYER_KIND } from './domain/deviceLayer.js'
- import { ANCHOR_HIDDEN, solvePointAnchor } from './domain/pointAnchor.js'
- import {
- overallFreshness,
- overallFreshnessText,
- sectionStateOf,
- viewFromSectionState,
- } from './domain/freshnessModel.js'
- import { INDUSTRIES, SCOPES, industryQuery, districtQuery, industryIcon } from './domain/filterOptions.js'
- import { VIEW_MODES } from './domain/viewModes.js'
- import { useUiStore } from './stores/uiStore.js'
- import { useFilterStore } from './stores/filterStore.js'
- import { usePipeNetworkStore } from './stores/pipeNetworkStore.js'
- import CockpitPanel from './components/CockpitPanel.vue'
- import CockpitIcon from './components/CockpitIcon.vue'
- import MapCanvas from './components/MapCanvas.vue'
- import PipeEnhancement from './components/PipeEnhancement.vue'
- // 票 25:地图信息内容块(挂在本外壳的状态面板内,不另起容器)。
- import MapInfoPanel from './components/MapInfoPanel.vue'
- import PanelPlaceholder from './components/PanelPlaceholder.vue'
- import PanelBlocks from './components/PanelBlocks.vue'
- import EventListPane from './components/EventListPane.vue'
- import EventDetailCard from './components/EventDetailCard.vue'
- import WarningResponsePane from './components/WarningResponsePane.vue'
- import StatusTag from './components/StatusTag.vue'
- import CockpitIntro from './components/CockpitIntro.vue'
- import SectionStateTag from './components/SectionStateTag.vue'
- import PointDetailCard from './components/PointDetailCard.vue'
- import { configStatusView } from './domain/configStatus.js'
- import { DETAIL_LEVEL } from './domain/detailFlow.js'
- import { homeLabel } from './domain/viewPresets.js'
- import { ensureCockpitMocks } from './mocks/index.js'
- // 票 14:启动计划(规格 09 的主接缝)——real 与 mock 同构启动,差别只剩 registerMocks。
- import { STARTUP_ACTION, planCockpitStartup } from './domain/startupPlan.js'
- // 票 41:监测点选择的统一入口(外壳的「重新定位」也走它,不发明第二条定位路径)。
- import { selectMonitoringPoint } from './pointSelection.js'
- // 票 46:模式图层计划(唯一裁决点)+ 模式切换的选择态语义判定。
- import { planModeLayers, planModeSelectionTransition } from './domain/modeLayerPlan.js'
- import { enhancementSelection, PIPE_ENHANCEMENT_KIND, planPipeEnhancement } from './domain/pipeEnhancement.js'
- import { useDetailStore } from './stores/detailStore.js'
- import { useDeviceLayerStore } from './stores/deviceLayerStore.js'
- import { useInfrastructureStore } from './stores/infrastructureStore.js'
- import { useMonitoringStore } from './stores/monitoringStore.js'
- import { useEventStore } from './stores/eventStore.js'
- // 票 20:总体概览模式的四块面板(态势总览由另外三块合成,见其 store 的说明)。
- import { useOverviewStore } from './stores/overviewStore.js'
- import { useRuntimeStore } from './stores/runtimeStore.js'
- import { useMapStore } from './stores/mapStore.js'
- import { MAP_OBJECT_KIND, MAP_SUMMARY_PHASE } from './domain/mapSummary.js'
- import { getCockpitConfigState, loadCockpitConfig } from './services/cockpitConfig.js'
- import {
- atHome,
- getAreaLimits,
- getAreaState,
- getFogState,
- getHomeView,
- getMapCanvasRect,
- getMapMarkerAnchor,
- hasMapMarker,
- onMapFrame,
- onCameraStateChange,
- publishPointSelectionHandle,
- publishDeviceSelectionHandle,
- setPanelSelectionLevel,
- getIntroConfig,
- // 票 25:底图措辞与数据来源署名(Cesium 署名已移出画面,改由状态面板承担)。
- getBasemapLabel,
- getDataSourceCredits,
- goHome,
- resetToIntroView,
- setAreaState,
- setGlobeSpin,
- setViewMode,
- } from './services/mapService.js'
- import { AREA_STATE, GLOBAL_IDLE_MS, areaBarActions, shouldReturnToIntro } from './domain/areaLimits.js'
- // 视觉 token 的唯一来源(票 06)。挂在本组件根元素上,靠继承作用于驾驶舱子树。
- import './styles/tokens.css'
- // 票 17(批次 4):字体面按授权门禁注入 —— 门禁不通过的字体不生成 @font-face,
- // 于是「无证据字体不可被解析为运行时字体」由结构保证,而非靠约定(规格 11)。
- import { fontStatus } from './domain/runtimeAssets.js'
- import { installFontFaces } from './domain/fontFaces.js'
- // 票 17 第七轮:模式按钮选中态底板 / 选中光晕经语义 key 解析成 CSS 变量注入,
- // 组件样式里不出现物理路径(规格 09 的 manifest 唯一入口纪律)。
- import { iconFor } from './domain/icons.js'
- const uiStore = useUiStore()
- const filterStore = useFilterStore()
- // 票 24:设施点位与管线是**独立于地图摘要**的一条读取路径(不同业务对象、不同口径),
- // 单独一个 store 持有,避免两种对象在同一个 store 里互相污染统计。
- const pipeNetworkStore = usePipeNetworkStore()
- const detailStore = useDetailStore()
- const mapStore = useMapStore()
- const infrastructureStore = useInfrastructureStore()
- const monitoringStore = useMonitoringStore()
- const overviewStore = useOverviewStore()
- const eventStore = useEventStore()
- const runtimeStore = useRuntimeStore()
- const statusPanelExpanded = ref(false)
- const { mode, expandedPanelIds } = storeToRefs(uiStore)
- const { scope, industry, deviceType, viewMode, pointStatus, areaState } = storeToRefs(filterStore)
- /**
- * 批次 1:行业筛选按钮带上图标(`all` 无图标,渲染时不画)。
- * 单独派生一份而不是就地改 `INDUSTRIES` —— 筛选契约本身不带视觉信息。
- */
- const industryChips = computed(() => INDUSTRIES.map((item) => ({ ...item, icon: industryIcon(item.key) })))
- /** 与筛选按钮文字配套的图标边长(走查反馈偏小,由 11 上调)。 */
- const industryIconSize = 'calc(14 * var(--u, 1px))'
- // ---- 四个已接数据的模式(票 17 基础设施 / 票 18 运行监测 / 票 19 预警处置 / 票 20 总体概览)----
- // 16 块面板现已全部接上真实视图;`PanelPlaceholder` 仍是未注册 panelId 的兜底。
- const { sections: infrastructureSections } = storeToRefs(infrastructureStore)
- const { sections: monitoringSections, badges: monitoringBadges } = storeToRefs(monitoringStore)
- // 票 20:概览四块的分区(态势总览那块由 `overviewStore.situationSection()` 合成,不在这个对象里)。
- const { sections: overviewSections } = storeToRefs(overviewStore)
- /**
- * 启动已就绪(配置落地且按启动计划执行完首轮取数)——筛选跟随只在这之后生效。
- * 票 14:原 `mockReady` 只在 mock 分支置位(real 不取数);同构启动后两种数据源
- * 都会置位,改名以如实表达语义。
- */
- const lifecycleReady = ref(false)
- /** 该 panelId 的分区(各 store 各查各的;都没接就回 null)。 */
- function sectionOf(panelId) {
- // 票 20:态势总览**不自己取数**,它的分区由另外三块合成(见 overviewStore 的说明)。
- if (panelId === 'situationSummary') return overviewStore.situationSection()
- if (Object.prototype.hasOwnProperty.call(overviewSections.value, panelId)) return overviewSections.value[panelId]
- if (Object.prototype.hasOwnProperty.call(infrastructureSections.value, panelId)) return infrastructureSections.value[panelId]
- if (Object.prototype.hasOwnProperty.call(monitoringSections.value, panelId)) return monitoringSections.value[panelId]
- // 票 19:预警处置四块共享同一个 mode,并各有独立分区状态。
- if (['currentEvents', 'historyAnalysis', 'responseEfficiency', 'eventAnalysis'].includes(panelId)) return eventStore.sections[panelId]
- return null
- }
- /** 还没取过数的分区(`sectionOf` 都不认识这个 panelId 时的兜底;正常情况下 store 自带 pending)。 */
- function pendingSection() {
- return { state: 'empty', reason: '', mode: '', data: null, missing: [], freshness: '', retained: false, loaded: false }
- }
- /**
- * panelId → 算好的视图计划(分发归 `domain/panelViews.js` 的注册表,外壳只做参数搬运)。
- *
- * **已接数据的面板一定有视图**(哪怕还没取数):domain 的视图计划对 `data: null` 仍返回
- * 结构完整的对象,其 `placeholder` 是「正在取数…」。所以「已实现」与「还没实现」由
- * `hasPanelView(panelId)` 分辨(那才决定走组件还是走占位),**不由视图在不在分辨** ——
- * 早先把两者混起来时,一个已接数据但还在加载的面板会被画成「面板占位 · panelId」,
- * 那是在说"这块还没做",与实际不符。
- *
- * 台账要额外喂一份徽标(票 18 的 N+1),所以只有它多带第三个参数 —— 这是本票唯一的
- * "某个面板需要额外输入"的地方,写在壳里比塞进注册表签名更清楚。
- */
- function panelViewOf(panelId) {
- if (!hasPanelView(panelId)) return null
- const section = sectionOf(panelId) || pendingSection()
- /** 台账要额外喂一份徽标(票 18 的 N+1);票 14:监测分布的饼图要喂全局未筛分布与当前选中。 */
- let extra
- if (panelId === 'monitoringPointLedger') extra = monitoringBadges.value
- else if (panelId === 'monitoringDistribution') {
- extra = { statusDistribution: mapStore.statusDistribution, selectedStatuses: pointStatus.value }
- }
- // 票 20:态势总览要**三份领域对象**(它横跨基础设施/运行监测/预警设置三块),
- // 与基础设施面板要「设施底数 + 行业构成 + 区域分布」一样,都是"这一块的数据来自哪几处"
- // 的搬运 —— 写在壳里,注册表签名保持 `(panelId, data, section, extra)` 不变。
- if (panelId === 'situationSummary') return panelView(panelId, overviewStore.situationData(), section, extra)
- if (panelId === 'infrastructureSummary') return panelView(panelId, overviewStore.infrastructureData(), section, extra)
- return panelView(panelId, section.data, section, extra)
- }
- /**
- * 面板标题右侧的提要:有数据的写一句口径摘要;**已接数据但还没取数**写「正在取数」;
- * 没实现的写「面板占位」—— 后两者必须分开,否则"正在加载"会被读成"这块还没做"。
- */
- function panelMeta(panelId) {
- // 票 13:当前预警面板的提要由「事件 + 切换」视图给(条数/状态来自事件分区)。
- if (panelId === 'currentEvents') {
- const view = eventStore.view
- const section = eventStore.sections.currentEvents
- if (!section || section.loaded !== true) return '正在取数'
- if (!view.hasContent) return '暂无事件'
- return view.limited ? `${view.rows.length} 条事件(最新)` : `${view.rows.length} 条事件`
- }
- if (!hasPanelView(panelId)) return '面板占位'
- const section = sectionOf(panelId)
- if (!section || section.loaded !== true) return '正在取数'
- const view = panelViewOf(panelId)
- if (!view || !view.hasContent) return '暂无数据'
- return `${view.blocks.length} 组读数`
- }
- /** 定位反馈:只有失败情形值得给一行字(成功时地图与详情卡自己会说话)。 */
- const locateFeedback = computed(() => {
- const current = infrastructureStore.locate
- if (!current || !current.result || current.result === LOCATE_RESULT.OK) return null
- return current
- })
- /**
- * 票 38:反馈浮层自动收起。失败提示要**当场看得见**(不再藏在折叠面板里),
- * 但也不能常驻——值班人员接着点下一个点位时,上一条的说明早该走了。
- * 定时器随结果变化重建:同一点位重取(result 相同但 pointCode 变)也算一次新反馈。
- * 手动「关闭」与自动到点走同一个 `clearLocate`(store 是唯一持有者,壳不自己抹状态)。
- */
- let locateDismissTimer = null
- watch(locateFeedback, (current) => {
- if (locateDismissTimer) {
- window.clearTimeout(locateDismissTimer)
- locateDismissTimer = null
- }
- if (!current) return
- locateDismissTimer = window.setTimeout(() => {
- locateDismissTimer = null
- infrastructureStore.clearLocate()
- }, 8000)
- })
- function dismissLocateFeedback() {
- if (locateDismissTimer) {
- window.clearTimeout(locateDismissTimer)
- locateDismissTimer = null
- }
- infrastructureStore.clearLocate()
- }
- /** 点面板里的可定位对象 → 地图定位(接缝在 store,本组件只管转发)。 */function locateOnMap(pointCode) {
- return infrastructureStore.locatePoint(pointCode)
- }
- /**
- * 票 18:点台账卡片 → 打开与地图**相同**的两级详情。
- *
- * 刻意不移动相机:地图上点标记也不动相机(ADR 0008 的既有语义),
- * 台账若顺手把镜头飞走,同一份详情会有两种手感(Q4 裁定的复用方式)。
- */
- function openLedgerCard(pointCode) {
- return monitoringStore.openLedgerPoint(pointCode)
- }
- /**
- * 票 14:监测分布面板的 `filter-status` 出口已随「面板自带筛选入口」一并移除——
- * 状态筛选的唯一入口是地图顶部状态栏(`MapCanvas` 直接写 filterStore),
- * 全局条件只有一个可操作面(决策 02 的 Q19)。
- */
- /** 票 18:台账「加载下一页」。 */
- function loadMoreLedger() {
- return monitoringStore.loadMoreLedger()
- }
- /**
- * 票 18:台账卡片进视口 → 补拉该点位的设备徽标(Q14 裁定:进视口才拉、逐点位、失败不牵连其它卡片)。
- *
- * 触发点在 `PanelBlocks.vue` 的 `IntersectionObserver`(组件只回答"这张卡露出来了"),
- * 取数与记账在 store —— 组件不认识接口,也不自己发请求。
- */
- function loadLedgerBadge(pointCode) {
- return monitoringStore.loadLedgerBadge(pointCode)
- }
- /**
- * 票 20 立的联动接缝(票 37 复用):渲染层只回答「哪一项被点了」(`{ kind, key }`),
- * **它不认识 panelId、也不认识业务语义**;把 `kind` 翻成动作收在这一处,
- * 与 `panelViews.js` 把 panelId 翻成视图计划是同一个立场。
- *
- * 票 37 增 `deviceType` 一种:点监测概览的类型分布行 → 写全局设备类型筛选
- * (toggle 语义在 filterStore:再点同一行取消)。**没有"地图定位"这一种**:
- * 概览/概览的端点都不含点位编码或坐标(见 `domain/overviewPanels.js` 文件头第 ① 条),
- * `link` 落到预警处置模式后,那条链路(事件带设备编码 → 票 13/14 跳点位)才是真实存在的。
- * @param {{kind: string, key: string}} action
- */
- function handlePanelAction(action) {
- if (!action) return false
- if (action.kind === 'link') return overviewStore.linkToMode(overviewLinkMode(action.key))
- if (action.kind === 'industry') return overviewStore.linkToIndustry(action.key)
- if (action.kind === 'deviceType') {
- filterStore.toggleDeviceType(action.key)
- return true
- }
- return false
- }
- /** `link` 键 → 目标一级模式(映射表在 domain,外壳不发明语义)。 */
- function overviewLinkMode(linkKey) {
- const found = (OVERVIEW_LINK_MODES || {})[linkKey]
- return found || ''
- }
- /** 票 21:视图条件的两个选项(决策地图票 05:全局视图条件,与范围/行业同级)。 */
- const VIEW_MODE_OPTIONS = [
- { key: VIEW_MODES.GROUND, label: '地面视图', hint: '常规俯视;管线隐藏、地形与模型不透明' },
- { key: VIEW_MODES.PIPELINE, label: '管线视图', hint: '压低弹簧臂、放宽俯仰;地形与模型半透明以露出管线' },
- ]
- /**
- * 切换视图:状态先进 store(全局条件,切一级模式不重置),再下发给地图服务。
- * 引擎没就绪时服务返回 false,稍后重试 —— 与「归位」同一套兜底。
- */
- function switchViewMode(next) {
- if (!VIEW_MODE_OPTIONS.some((item) => item.key === next)) return
- filterStore.setViewMode(next)
- // 票 21:倾角/透明度可由 map.viewMode 覆盖(缺省用 domain/viewModes.js 的默认)。
- const mapConfig =
- configState.value && configState.value.config && configState.value.config.map ? configState.value.config.map : null
- const overrides = (mapConfig && mapConfig.viewMode) || {}
- let tries = 0
- const apply = () => {
- tries += 1
- if (setViewMode(next, overrides) || tries >= 40) {
- // 票 37:视图切换会换 range 区间(管线 [400, 6000])并与区域限位取交集,
- // 所以顶栏那条「区内限位 · 最远 N m」要跟着刷新 —— 它是人工核对三处交集的读数。
- areaLimits.value = getAreaLimits()
- return
- }
- window.setTimeout(apply, 250)
- }
- apply()
- }
- const { sections, selection } = storeToRefs(detailStore)
- // ---- 票 42:关联监测设备临时图层 ----
- //
- // 本层是**合成点**:它读 mapStore 的当前快照 `deviceCodes`(总数依据)与 detailStore 的点位详情
- // `devices[]`(状态与设备级坐标),按 `domain/deviceLayer.js` 判定,再由地图服务画到独立图层。
- // 外壳负责三类触发(缺一条都会让图层停在过期状态):
- // ① **选择变化**:换点/关闭 → 立即重算(关闭时无点位级选择 ⇒ 撤下图层);
- // ② **详情到达**:设备级坐标与状态来自详情,它回来之前设备只能是「待确认」;
- // ③ **快照变化**:刷新/筛选重载/快照失效 → **先撤下旧图层**再等新的(规格明写,不许留旧数据)。
- const deviceLayerStore = useDeviceLayerStore()
- const { rows: deviceRows, counts: deviceCounts, legend: deviceLegend, notice: deviceNotice } = storeToRefs(deviceLayerStore)
- /** 最近一次「已撤下旧图层」对应的快照标记(同一个快照不重复撤,避免翻页时把图层打没)。 */
- let deviceSnapshotSeen = ''
- /**
- * 票 42(规格 14:刷新核对链)——外壳持有的**核对状态**:
- * · `snapshotPending`:刷新/失效发起后、核对收口前,设备图层挂起 + 顶栏「待核对」;
- * · `snapshotAbsentNote`:完整加载仍找不到所选点位 → 详情已关闭,顶栏说明原因。
- * 挂起与收口由下方三个监听驱动(发起 / 失效相位 / settled)。
- */
- const snapshotPending = ref(false)
- const snapshotAbsentNote = ref('')
- /**
- * 票 14:设备级筛选(行业/范围/类型)是否生效——生效时 map-snapshot 的 `deviceCodes`
- * 是**命中设备子集**,详情独有按「未命中当前筛选」归类(另有 N 台),而非快照不一致。
- * 状态筛选不算:它作用在对象状态上,不裁剪点位的设备清单。
- */
- const deviceFilterActive = computed(
- () => industryQuery(industry.value) !== '' || districtQuery(scope.value) !== '' || String(deviceType.value || '').trim() !== '',
- )
- function syncDeviceLayer(options = {}) {
- // 票 46:`entered` 跟随引导态 —— 进入前关联设备图层整体不呈现(规格 18 的引导列)。
- // 票 45:重叠展开阈值来自 `map.overlap.maxFan`,未配置时用规格默认 10。
- const mapConfig = configState.value && configState.value.config && configState.value.config.map
- const maxFan = options.maxFan || (mapConfig && mapConfig.overlap && mapConfig.overlap.maxFan) || 10
- return deviceLayerStore.syncForSelection({
- ...options,
- mode: mode.value,
- entered: !introVisible.value,
- parentVisible: !selection.value.pointCode || [...modeLayerPlan.value.markers, ...modeLayerPlan.value.blocked]
- .some((item) => item.code === selection.value.pointCode),
- maxFan,
- filterActive: deviceFilterActive.value,
- })
- }
- /**
- * 票 46:**异常设备集**(规格 18 的输入)。
- *
- * 来源是 `/monitoring/situation` 的 `abnormalEquipment[]`(运行监测「运行概况」面板的数据,
- * `monitoringStore.sections.operationOverview`)—— 那是**设备状态事实**,不是监测点聚合状态。
- * 它与 `map-snapshot` 跨端点:数据没取到时给 `null`(计划层会输出降级说明,不猜异常点)。
- */
- const modeAbnormalDevices = computed(() => {
- const section = monitoringSections.value.operationOverview
- const data = section && section.data
- return data && Array.isArray(data.abnormalEquipment) ? data.abnormalEquipment : null
- })
- /**
- * 票 46:**唯一**的模式图层计划(规格 18:引擎和组件只消费计划,不做第二次过滤)。
- *
- * 在外壳算一次(外壳是唯一同时握有 mapStore 对象、设备行、异常设备与选择态的层),
- * 地图组件、图例、详情暂停都只读这一份:
- * · `markers/legend` → MapCanvas(图例与标记同源,只统计本模式可见的标记);
- * · `selection.visible` → 详情浮层的**暂停/恢复**(目标模式不显示所选项时隐藏但保留选择);
- * · `degradedNotes` → 「设施/管线/异常设备数据未就绪」的如实说明。
- */
- const modeLayerPlan = computed(() => planModeLayers({
- mode: mode.value,
- objects: mapStore.summary.objects,
- abnormalDevices: modeAbnormalDevices.value,
- // 票 24:设施点位与管线接真实图层。取不到时由 store 收成失败分区,
- // 计划如实降级为「未就绪」——不再传 `null` 假装"还没接线"。
- // 过滤只在这一层做(`pipeNetworkStore.filtered`),引擎与组件都不做第二次过滤。
- facilities: pipeNetworkStore.facilities,
- pipelines: pipeNetworkStore.pipelines,
- associatedDevices: deviceRows.value,
- selection: {
- level: selection.value.level,
- pointCode: selection.value.pointCode,
- deviceCode: selection.value.deviceCode,
- },
- entered: !introVisible.value,
- }))
- /** 详情浮层要不要显示(规格 18 的选择态暂停:目标模式不显示所选项 → 暂停但保留选择身份)。 */
- const selectionVisibleInMode = computed(() => modeLayerPlan.value.selection.visible === true)
- /**
- * 快照变化 → 先撤下旧设备图层(规格:刷新或快照失效时先撤下,免得旧快照的设备被误读成新范围的)。
- * **同一个标记不重复撤**:翻页时标记不变,此时若也撤,会把正在看的设备层打没
- * —— 那正是规格禁止的「同一快照翻页造成闪烁」。
- *
- * 用 `batchUpdatedAt` 而不是 `summary.snapshot` 作判据:两者是同一份合并态里同批更新的
- * 等价标记(见 `domain/mapSummary.js` 的 `mergeMapPage`),但 `snapshot` 那个**字段名**
- * 会被 t08 的 H1 守卫命中 —— 该守卫查的是「界面不得出现分页/快照的**接口参数名**」
- * (票 08 验收第 2 条)。我不放宽守卫,改用不含该字样的等价字段。
- */
- function syncDeviceLayerForSnapshot(batchUpdatedAt) {
- const current = batchUpdatedAt || ''
- if (current && current === deviceSnapshotSeen) return
- deviceSnapshotSeen = current
- deviceLayerStore.invalidateForSnapshot({ filterActive: deviceFilterActive.value })
- syncDeviceLayer({ force: true })
- }
- /**
- * 票 42(规格 14:刷新核对链):快照链 settled 后对「挂起中的所选点位」做一次核对。
- *
- * 三种收口,全部是规格 14:86 的原句:
- * · **找到**(含被护栏截掉的前部页——用装载器的全页索引,不用合并态当证据):
- * 强制重取点位摘要(保留选择层级),设备图层经既有监听自行恢复;
- * · **确认无地图位置**:固定详情由既有 `fixedLocationReason` 呈现,无需额外处理;
- * · **完整加载仍找不到**:关闭详情并在顶栏说明原因。
- * 查找失败(failed 相位)**保持待核对**:已取数据与顶栏关闭入口都在,允许再次刷新重试。
- */
- function reconcileSnapshotSelection() {
- const pointCode = selection.value.pointCode
- if (!pointCode || selection.value.level === 'none') {
- snapshotPending.value = false
- return
- }
- const phase = mapStore.summary.phase
- // 失败与失效都是「查找失败」:保持待核对、已取数据与顶栏关闭入口,允许再次刷新重试
- // (规格 14:86 原句)——不得对已失效的快照做「找到即重载」。
- if (phase === MAP_SUMMARY_PHASE.FAILED || phase === MAP_SUMMARY_PHASE.EXPIRED) return
- if (mapStore.hasSnapshotObject('monitoring-point', pointCode)) {
- snapshotPending.value = false
- void detailStore.reloadPointDetail(pointCode)
- return
- }
- if (phase === MAP_SUMMARY_PHASE.COMPLETE || phase === MAP_SUMMARY_PHASE.EMPTY) {
- snapshotPending.value = false
- snapshotAbsentNote.value = `所选点位 ${pointCode} 已不在当前快照范围,详情已关闭`
- detailStore.closeDetail()
- }
- }
- /**
- * 票 46:模式切换的**选择态语义**(规格 18 / 决策票 13):
- * · 总览 ↔ 任一分项:保留选择身份;目标模式不显示所选项时**暂停**(详情浮层隐藏、设备图层撤下),
- * 切回总览且对象仍在当前范围时恢复 —— 恢复就是"计划重新判定为可见",不需要额外状态;
- * · 分项 ↔ 分项:清空选择(走 `detailStore.closeDetail()`,唯一的关闭入口,不另造状态)。
- * 判定在 `domain/modeLayerPlan.js` 的纯函数里,本壳只执行它的结论。
- */
- function applyModeSelectionTransition(fromMode, toMode) {
- const verdict = planModeSelectionTransition({
- fromMode,
- toMode,
- selection: {
- level: selection.value.level,
- pointCode: selection.value.pointCode,
- deviceCode: selection.value.deviceCode,
- },
- })
- if (verdict.action !== 'clear') return verdict
- // 分项→分项:清空。closeDetail 是唯一的关闭入口(不动相机、不换快照)。
- detailStore.closeDetail()
- return verdict
- }
- /** 引导态/进入系统后的联动:设备图层与锚定都要按 `entered` 重算一次。 */
- function syncLayersForEntered(nextEntered) {
- if (nextEntered) {
- syncDeviceLayer({ force: true })
- refreshAnchor({ force: true })
- } else {
- // 回到引导态(闲置回引导页):撤下设备图层(计划层已把一切输出置空)。
- syncDeviceLayer({ force: true })
- }
- }
- const stopDeviceWatches = []
- /** 票 10:两级详情是否打开(浮层只在有选择时渲染;关掉后地图与面板保持原样)。 */
- const detailOpen = computed(() => selection.value.level !== DETAIL_LEVEL.NONE)
- // ---- 票 41:详情面板锚定跟随监测点标记(决策票 04 / 规格票 12 的修订节)----
- //
- // 三条口径直接来自规格,不要在本层自行发明:
- // 1. **始终贴可见标记**:面板只避开画布边缘,**不再避让左右业务栏/图例/底部摘要**(R-1),
- // 被详情窗遮住是允许的、消失不是(R-4);
- // 2. **只在窗口完全离开画面时隐藏**(R-4),且**允许被画布裁切**(R-2)——
- // 所以 `no-space` / `icon-covered` 两种暂隐原因在本层**不再存在**(规格要求移除或标废弃);
- // 3. **层级低于四个详情窗**(R-3):`z-index` 取 1,压在 `--z-columns` 之下。
- //
- // 投影与落位都是**只读**的:地图服务给「标记在不在、投到哪儿」,本组件只摆 DOM,
- // 不持有 Cesium 实体,也不把帧坐标塞进任何业务 store。
- const detailRef = ref(null)
- const anchorLayout = ref({ visible: false, reason: 'canvas-missing', rect: null, line: null, side: '' })
- /** 面板坐标基准:`main` 的窗口矩形(投影给的是窗口坐标,两者相减才是 DOM 局部坐标)。 */
- const anchorOrigin = ref({ left: 0, top: 0 })
- /** 上一帧写下去的落位:与本次相同就**不再写 DOM**(规格:镜头稳定且布局未变时停止重算)。 */
- let lastAnchorKey = ''
- /** 摘要锚向监测点;设备详情锚向所选设备;业务归属编码保持不变。 */
- const detailTarget = computed(() => resolveDetailAnchor({
- selection: selection.value,
- objects: mapStore.summary.objects,
- devices: deviceRows.value,
- }))
- /** 详情卡上要显示的对象编码(顶栏暂隐入口读它)。 */
- const selectedDetailCode = computed(() => {
- const target = detailTarget.value
- return target ? target.code : ''
- })
- /**
- * 「无地图位置」的固定详情(票 04 的特例)。
- *
- * 判据由 `detailAnchorLocation()` 转交坐标准入领域函数,按当前点位或设备读取——
- * 外壳不自己调 `isRenderable` / `blockReason`:界面自己判坐标正是票 07 的 I3 守卫禁止的事。
- *
- * 三种情形分得开:对象**不在已加载快照里**(分页没翻到 / 引擎未就绪)**不算**无地图位置,
- * 那是"暂时无法锚定",走暂隐并保留选择;只有后端明确判不可上图才转固定详情。
- */
- const fixedLocationReason = computed(() => {
- const target = detailTarget.value
- if (!target) return ''
- const verdict = detailAnchorLocation(target)
- return verdict.present && !verdict.renderable ? verdict.reasonText : ''
- })
- const hiddenAnchorText = computed(() => ANCHOR_HIDDEN[anchorLayout.value.reason] || '点位标记当前不可见')
- /** 仅**标记仍有效**时才给重新定位入口(不对已消失的标记发无效定位)。 */
- const canRelocate = computed(() => {
- if (introVisible.value || !detailTarget.value || fixedLocationReason.value) return false
- return hasMapMarker(detailTarget.value.kind, detailTarget.value.code, detailTarget.value.pointCode)
- })
- /**
- * 求当前锚点的落位。
- * 返回 `{ canvas, layout }`:`canvas` 只作内部判定输入,`layout` 才是写进视图的对象
- * (暂隐与可见**同形**,模板才能只读一组字段,不会出现"有时读得到 reason、有时读不到")。
- */
- function measureAnchor() {
- const canvas = getMapCanvasRect()
- if (!canvas) return { canvas: null, layout: { visible: false, reason: 'canvas-missing', rect: null, line: null, side: '' } }
- const hidden = (reason) => ({ canvas, layout: { visible: false, reason, rect: null, line: null, side: '' } })
- const target = detailTarget.value
- if (!target) return hidden('marker-missing')
- if (!hasMapMarker(target.kind, target.code, target.pointCode)) return hidden('marker-missing')
- const anchor = getMapMarkerAnchor(target.kind, target.code, target.pointCode)
- // 投影拿不到(相机背面 / 地平线后 / 投影无效)与标记在画布外,都归"完全离开画面"。
- if (!anchor) return hidden('marker-offscreen')
- // 高度取 `scrollHeight`(内容自然高度,**不受已写入的显式高度影响**):
- // 用它做判据不会出现「设了高度→量到高度→又设回 auto」的来回抖动。
- const natural = detailRef.value ? detailRef.value.scrollHeight : 0
- const height = Number.isFinite(natural) && natural > 0 ? natural : 0
- return { canvas, layout: solvePointAnchor({ anchor, canvas, height, iconSize: anchor.iconSize || 18, gap: anchor.panelGap || 12 }) }
- }
- /**
- * 重算锚定落位。
- *
- * `options.force` 的由来(**真机走查抓到的真缺陷**):规格要求「页面不可见时停止计算」,
- * 原实现把 `document.hidden` 写成无条件早退 —— 于是标签页在后台时**连首次布局都不做**,
- * 面板永远停在初值 `canvas-missing` 上(走查环境正是这种标签页,整条锚定链路不可观测)。
- * 正确的分工是:
- * · **逐帧**重算(`force` 未设)在页面不可见时跳过 —— 那是规格要的省算力;
- * · **显式**重算(详情选择变化 / 尺寸变化 / 挂载,`force: true`)照常执行 ——
- * 它由用户动作驱动、次数极少,跳过只会让面板停留在错误位置。
- */
- function refreshAnchor(options = {}) {
- if (!detailOpen.value || introVisible.value || typeof document === 'undefined') return
- if (document.hidden && options.force !== true) return
- const main = detailRef.value ? detailRef.value.closest('.cockpit-shell__main') : null
- if (main) {
- const rect = main.getBoundingClientRect()
- anchorOrigin.value = { left: rect.left, top: rect.top }
- }
- const { layout } = measureAnchor()
- const key = layout.visible
- ? `${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.reason
- // 与上一帧相同就**不再写 DOM**(规格:镜头稳定且布局未变时停止重算)。
- if (key === lastAnchorKey) return
- lastAnchorKey = key
- anchorLayout.value = layout
- }
- /**
- * 面板落位。**用绝对定位**(不是 `fixed`):百分比 `max-height` 要按 `main` 解析,
- * 而 `fixed` 会按视口解析 —— 视口比地图区多出顶栏与筛选条那一条,阈值就会漂。
- */
- const detailStyle = computed(() => {
- if (fixedLocationReason.value) return {}
- const layout = anchorLayout.value
- // 暂隐:`visibility` 而不是 `display:none` —— 元素仍在 DOM 里,恢复时能直接量到自然高度
- // (`display:none` 会让 `scrollHeight` 变 0,恢复后面板会先塌成 1px)。
- // **必须同时关掉 `pointer-events`**:本组件是 `pointer-events: auto` 的绝对定位浮层,
- // 只隐藏视觉仍会**拦截它覆盖区域内的地图点击**(看不见却吃掉交互)。
- if (!layout.visible) return { visibility: 'hidden', pointerEvents: 'none' }
- const origin = anchorOrigin.value
- return {
- left: `${layout.rect.left - origin.left}px`,
- top: `${layout.rect.top - origin.top}px`,
- width: `${layout.rect.width}px`,
- // 内容没超过可读上限就按内容高(不留空白),超了才钉住高度并由卡内滚动接管。
- height: layout.cropped ? `${layout.rect.height}px` : 'auto',
- }
- })
- /** 归属短线:从图标外缘指向卡片面对图标的那条边(不覆盖图标)。 */
- const anchorLeadStyle = computed(() => {
- const line = anchorLayout.value.line
- if (!line) return {}
- // 与卡片同一坐标基准:投影给的是**窗口坐标**,短线与卡片同处 `main` 内(绝对定位),
- // 所以两端都要减掉基准点 —— 只减卡片那一处会让线整体偏出一个顶栏+筛选条的高度。
- const origin = anchorOrigin.value
- const from = { x: line.from.x - origin.left, y: line.from.y - origin.top }
- const to = { x: line.to.x - origin.left, y: line.to.y - origin.top }
- const dx = to.x - from.x
- const dy = to.y - from.y
- const length = Math.hypot(dx, dy)
- const angle = (Math.atan2(dy, dx) * 180) / Math.PI
- return {
- left: `${from.x}px`,
- top: `${from.y}px`,
- width: `${length}px`,
- transform: `rotate(${angle}deg)`,
- }
- })
- /** 暂隐入口的「重新定位」:只对仍有效的监测点标记走统一选择入口(不发明第二条定位路径)。 */
- function relocateSelected() {
- const target = detailTarget.value
- if (!target) return
- if (target.kind === DEVICE_LAYER_KIND) deviceLayerStore.selectDevice(target.code)
- else if (target.kind === MAP_OBJECT_KIND.MONITORING_POINT) selectMonitoringPoint(target.code)
- }
- /**
- * 详情选择或地图对象变化 → 立即重算一次落位(不等下一帧)。
- * `nextTick` 是等卡片挂载/换内容后再量高度:刚打开时 `scrollHeight` 还是 0,
- * 直接量会把长卡判成"没内容"而永远按 auto 摆放。
- */
- watch(
- [() => selection.value.level, () => selection.value.pointCode, () => selection.value.deviceCode, () => mapStore.summary.objects, () => deviceRows.value],
- () => {
- lastAnchorKey = ''
- nextTick(() => refreshAnchor({ force: true }))
- },
- { deep: false },
- )
- // ---- 共享数据分区(票 05):三条共享详情域分区在筛选条右侧列出状态 ----
- // 标签用短名,状态语义仍由 SectionStateTag + domain/sectionState.js 决定。
- const SECTION_LABELS = {
- mapSnapshot: '地图摘要',
- equipmentAggregate: '设备详情',
- equipmentFreshness: '数据新鲜度',
- }
- const sharedSections = computed(() =>
- Object.keys(SECTION_LABELS).map((name) => ({
- name,
- label: SECTION_LABELS[name],
- section: sections.value[name],
- })),
- )
- /**
- * 票 16 验收第 2 条要求「新鲜、过期、不可用和**部分失败**状态可区分」。
- *
- * 「部分失败」是**跨分区**的性质:单个分区只会说自己那一份(`sectionView`),
- * 谁也不负责说"三块里坏了一块"。所以这里把各分区的四态收上来交给
- * `domain/freshnessModel.js` 的 `overallFreshness()`,由它给出整体结论 ——
- * 判据只有一份,界面不自己数数。
- */
- const overallFreshnessState = computed(() =>
- overallFreshness(sharedSections.value.map((item) => viewFromSectionState(sectionStateOf(item.section)))),
- )
- const overallFreshnessLabel = computed(() => overallFreshnessText(overallFreshnessState.value))
- // ---- 地图摘要渐进加载(票 08):顶栏只展示状态与一个动作,分页/快照参数都在适配层 ----
- const mapSummaryView = computed(() => mapStore.view)
- /** 收起态只留一条最新状态,展开态才显示全部诊断字段。 */
- const statusSummaryText = computed(() => {
- if (overallFreshnessState.value === 'partial') return overallFreshnessLabel.value
- if (['empty', 'stale', 'unavailable'].includes(runtimeStore.freshness.view)) return runtimeStore.freshness.text
- if (runtimeStore.status.state !== 'open') return runtimeStore.statusText
- if (['idle', 'loading', 'empty', 'failed', 'expired'].includes(mapSummaryView.value.phase)) return mapSummaryView.value.label
- return `更新 ${runtimeStore.freshness.lastSuccessClock}`
- })
- const statusSummaryTone = computed(() => {
- if (overallFreshnessState.value === 'partial') return 'is-warning'
- if (['empty', 'stale', 'unavailable'].includes(runtimeStore.freshness.view)) return 'is-warning'
- if (runtimeStore.status.state !== 'open') return 'is-warning'
- if (['idle', 'loading', 'empty', 'failed', 'expired'].includes(mapSummaryView.value.phase)) return 'is-warning'
- return 'is-ok'
- })
- /**
- * 票 14:手动刷新(状态栏最右侧的刷新动作,决策 02 的 Q25)=换新快照并**自动从第 1 页加载到完成**
- * (旧「加载下一页 / 刷新快照」两个按钮已移除;自动链归 mapStore 的装载链)。
- */
- async function refreshMapSnapshot() {
- await mapStore.refresh()
- // 地图摘要分区状态跟着装载状态走(票 05 的分区板据此变色)。
- detailStore.syncMapSnapshotSection()
- }
- /**
- * 票 14(规格 09 的 tick 全套):新鲜度周期的面板半边 —— 刷新**当前一级模式**的全部面板。
- * 面板取数失败不抛(各分区自己收成 UNAVAILABLE 并如实标注),地图刷新失败才是
- * tick 记账的失败来源;两者都不做无限重试(决策 04:恢复靠周期与手动入口)。
- */
- async function refreshCurrentModePanels() {
- const current = mode.value
- if (current === 'monitoring') {
- await monitoringStore.loadPanels({
- industry: industry.value,
- deviceType: deviceType.value,
- pointStatus: pointStatus.value,
- scope: scope.value,
- })
- } else if (current === 'infrastructure') {
- await infrastructureStore.loadPanels({ industry: industry.value, scope: scope.value })
- } else if (current === 'warningResponse') {
- await eventStore.loadPanels({ mode: eventStore.mode, industry: industry.value })
- } else {
- await overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
- }
- }
- // ---- 配置状态(票 04):载入前不渲染标签,避免先闪一下「缺失」再变「就绪」 ----
- const configLoaded = ref(false)
- const configState = ref(null)
- const configView = computed(() => configStatusView(configState.value))
- /**
- * 票 17:字体授权门禁的宿主读数(走查断言接缝)。
- * `fontStatus()` 与 `installFontFaces()` 同源——都读统一资产解析层的门禁判定,
- * 所以读数不会与页面实际注入的 @font-face 出现两套说法。
- */
- const cockpitFontStatus = computed(() => fontStatus())
- const fontGate = computed(() => (cockpitFontStatus.value.ok ? 'ok' : cockpitFontStatus.value.code))
- const fontFaceCount = computed(() => String(cockpitFontStatus.value.entries.filter((e) => e.resolvable).length))
- /**
- * 票 17 第七轮:把导航装饰切图(选中态底板 / 选中光晕 / 底边光路)经语义 key 解析成
- * CSS 自定义属性挂在顶栏上——样式文件里只引用 var(),不出现物理路径;
- * manifest 缺条目时变量为 none,对应装饰自动退化(底板退 CSS 渐变、光晕/光路不画)。
- * 票 18(批次 5):新增品牌标题后方光池(icon.brand.pool),同样语义注入、缺失自动不画。
- */
- const navDecorStyle = computed(() => {
- const bg = iconFor('navActiveBg')
- const glow = iconFor('brandGlow')
- const path = iconFor('brandGlowPath')
- const pool = iconFor('brandPool')
- return {
- '--nav-active-bg': bg && bg.url ? `url("${bg.url}")` : 'none',
- '--nav-glow-url': glow && glow.url ? `url("${glow.url}")` : 'none',
- '--brand-path-url': path && path.url ? `url("${path.url}")` : 'none',
- '--brand-pool-url': pool && pool.url ? `url("${pool.url}")` : 'none',
- }
- })
- /**
- * 票 32:UI 形态。配置 `map.ui.debug` 显式给布尔值就听它,否则跟随构建模式
- * —— 开发构建显示诊断信息,生产构建自动收敛成「干净模式」(维护者 2026-09-18 要求)。
- */
- const uiDebug = computed(() => {
- const configured =
- configState.value && configState.value.config && configState.value.config.map && configState.value.config.map.ui
- ? configState.value.config.map.ui.debug
- : null
- if (typeof configured === 'boolean') return configured
- return !!(import.meta.env && import.meta.env.DEV)
- })
- // ---- 进入引导与视角预设(票 30)----
- // 引导默认开着;配置里 map.intro.enabled=false 时直接进系统(其它票的走查用它自隔离)。
- const intro = ref(getIntroConfig())
- const introVisible = ref(intro.value.enabled)
- const atHomeView = ref(false)
- const homeView = computed(() => getHomeView())
- const homeTitle = computed(() => `回到${homeLabel(homeView.value)}`)
- // ---- 主场景区:进区锁定与区域限位(票 37 / ADR 0009)----
- //
- // 状态本体在 `filterStore.areaState`(全局视图条件,切一级模式不重置);服务层负责转移。
- // 外壳只做三件它才有资格做的事:顶栏按钮形态、闲置计时、引导页显隐。
- const areaLocked = computed(() => areaState.value === AREA_STATE.LOCKED)
- const areaBar = computed(() => areaBarActions(areaState.value))
- /** 生效限位(区外是宽区间;区内上限=home.range)。按钮提示里读它,不另算一套。 */
- const areaLimits = ref(getAreaLimits())
- /**
- * 进区受阻时的如实提示(主场景区未配置 / 非法)。
- * **不弹窗、不遮罩**:与票 04/05 的立场一致 —— 单个能力不可用只标出来,不挡业务。
- */
- const areaNotice = ref('')
- /** 把服务层的区域状态同步回 store(服务层是转移的判定者,store 是持有者)。 */
- function syncAreaState() {
- const next = getAreaState()
- if (next !== areaState.value) filterStore.setAreaState(next)
- areaLimits.value = getAreaLimits()
- fogReadout.value = getFogState()
- }
- // ---- 票 38:自建视距雾的可观测读数 ----
- //
- // 外壳**只读**服务层的读数,不自己算强度/支持度(策略与生命周期归 mapService)。
- const fogReadout = ref(getFogState())
- /** 芯片文案:支持/可执行/强度三项说清楚;降级时说降级原因。 */
- const fogText = computed(() => {
- const fog = fogReadout.value
- if (fog.state === 'unsupported') return '视距雾不可用 · 无深度支持'
- if (fog.state === 'failed') return '视距雾不可用'
- if (fog.degrade === 'reduced') return `视距雾减弱 · 强度 ${fog.strength.toFixed(2)}`
- if (fog.degrade === 'off') return '视距雾已暂关'
- if (fog.phase === 'global') return '视距雾待命 · 区内生效'
- return `视距雾 ${fog.strength.toFixed(2)} · ${Math.round(fog.startDistance / 1000)}–${Math.round(fog.endDistance / 1000)} km`
- })
- /** 悬停说明:把"就绪不等于已遮住边缘"这条口径写在人能读到的地方。 */
- const fogTitle = computed(() => {
- const fog = fogReadout.value
- const lines = [
- `雾色 ${fog.color}`,
- `起雾 ${Math.round(fog.startDistance)} m · 完全遮蔽 ${Math.round(fog.endDistance)} m`,
- `阶段 ${fog.phase} · 强度 ${fog.strength}`,
- '「就绪」只表示处理级可执行,不代表矩形边缘已被遮住',
- ]
- if (fog.reason) lines.push(fog.reason)
- return lines.join(';')
- })
- /**
- * 返回全球(ADR 0009):解除限位 + 阻尼飞到程序初始态全景,之后完全自由。
- * 引擎未就绪时服务层返回 `applied: false` —— 如实不生效,不假装飞了。
- */
- function returnToGlobal() {
- const result = setAreaState('returnGlobal')
- syncAreaState()
- if (result.applied) {
- // 回全球后不久就该由闲置计时接管(回引导页),先把按钮状态刷一次。
- window.setTimeout(refreshHomeState, 900)
- }
- return result
- }
- /**
- * 进区(点「进入系统」/「归位」时随 `goHome` 一起发生)。
- * **主场景区不可用时进不去**:服务层返回 blocked,这里如实说明,不假装锁上了
- * (票 36 的关闭路径 —— 未配置 / 顺序非法时限位与雾气本来就该整体关闭)。
- */
- function enterArea() {
- const result = setAreaState('enter')
- syncAreaState()
- return result
- }
- /** 进入系统:停自转 + 进区 + 飞到目标城市预设视角。 */
- function enterSystem() {
- introVisible.value = false
- setGlobeSpin(0)
- enterArea()
- // 进区失败(主场景区未配置/非法)也要说一句:否则点了「进入系统」什么都没发生。
- if (!areaLocked.value) areaNotice.value = '主场景区未配置或配置无效 · 区域限位与雾气关闭'
- // 引导层可能比引擎先就绪(引擎要拉地形/影像):没下发成功就重试,直到相机接上。
- let tries = 0
- const fly = () => {
- tries += 1
- if (goHome({ immediate: false }) || tries >= 40) {
- window.setTimeout(refreshHomeState, 900)
- return
- }
- window.setTimeout(fly, 250)
- }
- fly()
- window.setTimeout(refreshHomeState, 900)
- }
- function refreshHomeState() {
- atHomeView.value = atHome()
- // 票 37:区域状态也在这里对齐。服务层是转移的判定者(`goHome` 内部会进区),
- // 而 DEV 把手 `__cockpitMap.goHome()` / `enterArea()` 走的是**服务层直调**,不经过本组件的
- // `enterArea()` —— 不同步的话,走查用把手进区后顶栏仍显示「归位/进入」、也不出「返回全球」,
- // 与实际锁定状态不符(本票真机走查第一次跑就撞上了)。这个 500ms 轮询是既有的,
- // 借用它让两条入口最终一致,不新增定时器。
- syncAreaState()
- }
- /** 归位按钮:引擎未就绪时重试(与进入系统同一条路径)。 */
- function returnHome() {
- // 票 37:归位即进区(服务层的 `goHome` 内部也调一次,幂等);不可用时如实提示。
- enterArea()
- if (!areaLocked.value) areaNotice.value = '主场景区未配置或配置无效 · 区域限位与雾气关闭'
- let tries = 0
- const fly = () => {
- tries += 1
- if (goHome({ immediate: false }) || tries >= 40) {
- window.setTimeout(refreshHomeState, 900)
- return
- }
- window.setTimeout(fly, 250)
- }
- fly()
- }
- const columns = computed(() => columnsForMode(mode.value))
- function isExpanded(panelId) {
- return expandedPanelIds.value.includes(panelId)
- }
- // ---- 全球态闲置 → 回程序初始态(票 37 / ADR 0009)----
- //
- // 「全球态闲置 5 秒未操作 → 回程序初始态(星球自转恢复 + 屏幕中央闪烁『点击进入系统』)」。
- // 三条边界都是契约明写的,写在代码里免得日后被"优化"掉:
- // 1. **只在 global 态计时** —— 区内是工作状态,不该被闲置踢回引导页;
- // 2. **任何交互即重置**(指针/滚轮/键盘/点按钮都算);
- // 3. **不重置一级模式与筛选**(只回视角与引导层,不动工作上下文)。
- // `map.intro.enabled=false` 时不发生(没有引导页可回 —— spec 的边缘口径)。
- let idleSince = 0
- let idleTimer = null
- /** 记一次交互(重置闲置计时)。任何用户动作都应调它。 */
- function markActivity() {
- idleSince = Date.now()
- }
- /**
- * 回到程序初始态:相机回初始全景 + 恢复自转 + 显示引导层
- * (不动 mode / 筛选 / 区域状态)。
- *
- * 维护者 2026-09-30 报障:原先只显示引导层 + 起转,**没有把相机送回去** ——
- * 于是画面停在用户最后停留的视角上、地球也不转,除了中央那行字就看不出来"回引导态了"。
- * 现在三件事一起做:`resetToIntroView()` 把角度送回 `0/−60/2×10⁷`,
- * `setGlobeSpin()` 立起自转意图(相机若还在飞,落定后由服务层自动接上)。
- */
- function returnToIntro() {
- introVisible.value = true
- resetToIntroView()
- setGlobeSpin(intro.value.spinSeconds)
- // 回引导页不等于解除限位:区外本来就是 global,区内不该被动摇(契约只说全球态闲置)。
- markActivity()
- }
- function checkIdle() {
- if (!idleSince) {
- markActivity()
- return
- }
- const elapsed = Date.now() - idleSince
- if (shouldReturnToIntro(elapsed, getAreaState(), { idleMs: GLOBAL_IDLE_MS, introEnabled: intro.value.enabled })) {
- if (!introVisible.value) returnToIntro()
- // 已经在引导页:继续重置,避免每 250ms 重复触发。
- markActivity()
- }
- }
- /**
- * 全局交互监听:任何指针/滚轮/键盘动作都重置闲置计时。
- * 捕获阶段监听,免得被面板自己的 `stopPropagation` 吃掉。
- */
- function onAnyActivity() {
- markActivity()
- }
- // ---- 顶栏时钟 ----
- const clockText = ref('')
- let clockTimer = null
- let homeTimer = null
- function tick() {
- const now = new Date()
- const pad = (n) => String(n).padStart(2, '0')
- clockText.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
- }
- /**
- * 票 41:锚定跟随的帧驱动与恢复。
- *
- * `onMapFrame` 走 Cesium 的 `scene.postRender`(**真的渲染了一帧**才回调)——
- * 不是自己再排一个 rAF:隐藏标签页里 rAF 会被冻结,而 `postRender` 只在渲染时触发,
- * 与「每帧最多一次、画面已渲染才投影」的规格口径一致。
- * 这里只对**当前一个**锚点做投影,没有详情时回调本身就是早退(零开销)。
- */
- let stopMapFrame = null
- let stopCameraState = null
- let anchorResizeHandler = null
- onMounted(() => {
- tick()
- clockTimer = setInterval(tick, 1000)
- // 归位按钮的高亮状态跟着相机走(用户手动平移后就不再算"在家")。
- homeTimer = setInterval(refreshHomeState, 500)
- // 票 17:字体面只在这个入口注入一次,随外壳生命周期存亡(幂等,重复调用不会叠加节点)。
- installFontFaces()
- // 票 37:闲置计时只在这个入口挂一次,随外壳生命周期存亡。
- markActivity()
- idleTimer = setInterval(checkIdle, 250)
- for (const domEvent of ['pointerdown', 'pointermove', 'wheel', 'keydown']) {
- window.addEventListener(domEvent, onAnyActivity, { capture: true, passive: true })
- }
- // 票 04:配置只在这个入口载入一次(服务内部有缓存),地图服务按需 await 同一份。
- loadCockpitConfig().then((result) => {
- configState.value = result
- configLoaded.value = true
- // 配置到手才知道引导开关与预设坐标,重新取一次。
- intro.value = getIntroConfig()
- introVisible.value = intro.value.enabled
- // ── 票 14:按**启动计划**执行(规格 09 / 决策票 04)──
- //
- // `dataSource` 只决定 transport,不决定页面生命周期:real 与 mock 产出**同一套动作**
- // (地图装载器、共享分区、四模式面板、WS、新鲜度),差别只剩 mock 分支多一步
- // `register-mocks`。此前的 `if (dataSource === MOCK)` 门让 real 成为空壳——
- // 规格明令移除;失败语义(分区 UNAVAILABLE / NO_BASE_URL)由 requestCockpitJson
- // 与 sectionState 对两种数据源一视同仁,**没有**任何「real 失败回退 mock」的路径。
- const plan = planCockpitStartup(result.config)
- for (const action of plan.actions) {
- if (action === STARTUP_ACTION.REGISTER_MOCKS) {
- ensureCockpitMocks()
- continue
- }
- // 票 18:**先把筛选交给地图**,再让共享分区去取第 1 页(顺序纪律不变)。
- // 若筛选晚于它,第 1 页就带着空筛选发出去 —— 那正是"筛了但第一屏没筛上"的经典来源。
- // (`mapStore.setup()` 是合并语义,所以后到的调用不会抹掉这里的条件。)
- // 票 14:refresh 链会**自动分批加载到完成**(决策 02 的 Q24)。
- if (action === STARTUP_ACTION.LOAD_MAP_SUMMARY) {
- mapStore.setup({
- config: result.config,
- industry: industry.value,
- scope: scope.value,
- deviceType: deviceType.value,
- pointStatus: pointStatus.value,
- })
- continue
- }
- if (action === STARTUP_ACTION.LOAD_SHARED_SECTIONS) {
- // 票 14:共享分区预载也吃**同一份**启动配置。`loadSharedSections` 内部会对
- // mapStore 再做一次合并式 setup —— 不显式下发的话,`shared = { config: undefined }`
- // 会把 LOAD_MAP_SUMMARY 刚合并进 mapStore 的 config 键覆盖掉(合并语义下
- // `undefined` 也是一次写入),real 下首批摘要请求就只能靠配置缓存兜底了。
- detailStore.loadSharedSections({ config: result.config })
- // 票 24:设施点位与管线是**跨模式共享**的图层(不随一级模式切换重取),
- // 所以跟共享分区一起启动,而不是塞进某个模式的 loadPanels。
- pipeNetworkStore.load({ industry: industry.value, config: result.config })
- continue
- }
- // 筛选用全局条件的当前值(CONTEXT.md:行业筛选作用于整个驾驶舱)。
- if (action === STARTUP_ACTION.LOAD_INFRASTRUCTURE_PANELS) {
- infrastructureStore.configure({ config: result.config })
- infrastructureStore.loadPanels({ industry: industry.value, scope: scope.value })
- continue
- }
- if (action === STARTUP_ACTION.LOAD_MONITORING_PANELS) {
- monitoringStore.configure({ config: result.config })
- monitoringStore.loadPanels({
- industry: industry.value,
- deviceType: deviceType.value,
- pointStatus: pointStatus.value,
- scope: scope.value,
- })
- continue
- }
- if (action === STARTUP_ACTION.LOAD_EVENT_PANELS) {
- eventStore.configure({ config: result.config })
- eventStore.loadPanels({ mode: 'WARNING', range: '7d', industry: industry.value })
- continue
- }
- if (action === STARTUP_ACTION.LOAD_OVERVIEW_PANELS) {
- overviewStore.configure({ config: result.config })
- overviewStore.loadPanels({ industry: industry.value, scope: scope.value })
- continue
- }
- // 票 15:状态推送。外壳只说"用配置启动"——地址、凭据与 mock/real 的连接方式
- // 都由 runtimeStore 决定(页面因此不认识凭据,也不认识假连接)。
- // 没配地址时服务进 `disabled`,界面如实说"未配置",不假装连上。
- if (action === STARTUP_ACTION.START_WEBSOCKET) {
- runtimeStore.startWithConfig(result.config, {
- scenario: (typeof window !== 'undefined' && window.__COCKPIT_WS_SCENARIO__) || 'success',
- })
- continue
- }
- // 票 16/14:刷新周期(决策地图票 07 Q3 = 前端轮询 + WS 断线也算失败周期)。
- // 规格 09 的 tick 全套:地图摘要刷新 + **当前模式面板取数**,成败交给新鲜度记账;
- // 连续两个周期失败即过期(有旧值)或不可用(从未取到)。
- // `__COCKPIT_FRESHNESS_FAIL__` 让走查/人工演示"周期一直失败"这条路径。
- if (action === STARTUP_ACTION.START_FRESHNESS) {
- const failForever = typeof window !== 'undefined' && window.__COCKPIT_FRESHNESS_FAIL__ === true
- runtimeStore.startFreshness({
- refreshSeconds: result.config.runtime ? result.config.runtime.refreshSeconds : undefined,
- tick: async () => {
- if (failForever) return { ok: false, reason: '演示:取数持续失败' }
- try {
- await mapStore.refresh()
- await refreshCurrentModePanels()
- return { ok: true }
- } catch (error) {
- return { ok: false, reason: String((error && error.message) || error) }
- }
- },
- })
- }
- }
- lifecycleReady.value = true
- })
- // 引导期间显示自转;进入后停(引擎就绪前调用会被忽略,等引擎 ready 再兜一次)。
- if (intro.value.enabled) {
- setGlobeSpin(intro.value.spinSeconds)
- const spinTimer = setInterval(() => {
- if (!introVisible.value || setGlobeSpin(intro.value.spinSeconds)) clearInterval(spinTimer)
- }, 600)
- } else {
- // 票 37:`intro.enabled=false`(走查自隔离)时**按 global 自由视角启动、不弹引导页**
- // (ADR 0009 的边缘口径)。这里显式同步一次状态,免得服务层与 store 各持一个初值。
- syncAreaState()
- }
- // 票 41:锚定跟随。帧驱动走地图的已渲染帧回调;尺寸变化与详情选择变化各补一次立即重算
- // (规格:恢复或尺寸变化后立即重算,不等下一帧)。
- stopMapFrame = onMapFrame(() => refreshAnchor())
- // 相机状态变化也重算(隐藏标签页里渲染帧不触发,这条才是可靠的跟随路径;见 mapService 的注释)。
- // 它由输入/飞行驱动、次数有限,所以同样走 `force`(不受"页面不可见"早退影响)。
- stopCameraState = onCameraStateChange(() => refreshAnchor({ force: true }))
- anchorResizeHandler = () => refreshAnchor({ force: true })
- window.addEventListener('resize', anchorResizeHandler)
- // 票 41:把统一选择入口递给 DEV 把手(走查/人工演示走同一条路径,不另造定位入口)。
- publishPointSelectionHandle(selectMonitoringPoint)
- // 票 43:设备选中/hover 的 DEV 把手也走 store(避免服务层与 store 出现两套选中真相)。
- publishDeviceSelectionHandle({
- selectDevice: (code) => deviceLayerStore.selectDevice(code),
- hoverDevice: (code) => deviceLayerStore.hoverDevice(code),
- backToPointLevel: () => deviceLayerStore.backToPointLevel(),
- })
- refreshAnchor({ force: true })
- // 票 42:三类触发都接上(选择 / 详情到达 / 快照变化)。
- // 选择与详情分开监听是有意的:选择变化要**立即**撤旧图层,而详情到达只需重算同一份计划。
- stopDeviceWatches.push(
- watch(() => [selection.value.pointCode, selection.value.level], () => syncDeviceLayer({ force: true }), { deep: false }),
- watch(modeAbnormalDevices, () => syncDeviceLayer(), { deep: false }),
- watch(() => sections.value.pointDetail, () => syncDeviceLayer(), { deep: false }),
- watch(() => mapStore.summary.batchUpdatedAt, (stamp) => syncDeviceLayerForSnapshot(stamp), { deep: false }),
- // A filter reload can retain the backend timestamp while changing device membership.
- watch(() => mapStore.summary.objects, () => syncDeviceLayer(), { deep: false }),
- // 票 42(规格 14:刷新核对链)——三个时刻:
- // ① 刷新**发起**:立即挂起设备图层(规格「先撤下」,不等首页返回);
- // ② 快照**失效**:expired 相位不改 batchUpdatedAt,必须单独触发撤层;
- // ③ 链条 **settled**:对挂起中的所选点位核对收口(找到重载 / 完整找不到关闭 / 失败保持待核对)。
- watch(() => mapStore.refreshing, (on) => {
- if (!on) return
- deviceLayerStore.suspendForSnapshot()
- snapshotAbsentNote.value = ''
- snapshotPending.value = Boolean(selection.value.pointCode)
- }, { deep: false }),
- watch(() => mapStore.summary.phase, (phase) => {
- if (phase !== MAP_SUMMARY_PHASE.EXPIRED || !selection.value.pointCode) return
- deviceLayerStore.suspendForSnapshot()
- snapshotPending.value = true
- }, { deep: false }),
- watch(() => mapStore.settled, (settled) => {
- if (settled && snapshotPending.value) reconcileSnapshotSelection()
- }, { deep: false }),
- // 一级模式变化只改**可见性与选择态**(不换快照、不重建地图):
- // 先按矩阵执行选择态语义(总览↔分项保留 / 分项↔分项清空),再重算设备图层计划。
- watch(mode, (next, prev) => {
- applyModeSelectionTransition(prev, next)
- syncDeviceLayer({ force: true })
- }, { deep: false }),
- // 票 46:引导态 ⇄ 进入系统的门控(规格 18:entered 决定一切业务对象显隐)。
- watch(introVisible, (next) => syncLayersForEntered(!next), { deep: false }),
- // 票 43:详情层级写进宿主 `data-map-panel-selection`,并把层级对照同步给设备图层 store
- // (镜头联动判"该怎么动"要拿它当"变化前")。
- watch(
- () => [selection.value.level, selection.value.deviceCode],
- ([level, deviceCode]) => {
- setPanelSelectionLevel(level)
- deviceLayerStore.noteLevel(level, deviceCode)
- },
- { deep: false, immediate: true },
- ),
- )
- })
- /**
- * 票 17:全局行业筛选变化 → 重取四块面板(CONTEXT.md:行业筛选是作用于整个驾驶舱的全局条件,
- * 所以它必须真的作用到面板数据上,而不只是记在 store 里)。
- *
- * 只在 mock 取数已就绪后跟随:配置还没落地时 store 里还没有 config,
- * 这时抢跑会带着空配置发请求(票 04 的「没有显式配置就不发请求」)。
- */
- watch(industry, (next) => {
- if (!lifecycleReady.value) return
- infrastructureStore.loadPanels({ industry: next })
- // 票 18:运行监测三块也跟随;地图换新快照重载(Q9:丢弃旧范围的已加载对象)。
- monitoringStore.loadPanels({ industry: next })
- // 票 13:当前预警的事件列表跟随全局行业(`/events/current` 收 industry 入参)。
- eventStore.loadPanels({ mode: eventStore.mode, industry: next })
- // 票 20:概览只有「运行监测」那块收行业入参(另两块不收,store 内部只重取能筛的那块)。
- overviewStore.loadPanels({ industry: next })
- // 票 24:行业筛选同样作用到管线与构件图层。几何是**全量取回、本地裁剪**,
- // 所以切行业只改本地筛选条件、不重新发请求(切换必须即时,不等一个往返)。
- pipeNetworkStore.setIndustry(next)
- refreshMapForFilters({ industry: next })
- })
- /** 片区筛选变化:地图、监测分布、基础档案、管理计数与概览片区分布同步刷新。 */
- watch(scope, (next) => {
- if (!lifecycleReady.value) return
- infrastructureStore.loadPanels({ scope: next })
- overviewStore.loadPanels({ scope: next })
- monitoringStore.loadPanels({ scope: next })
- refreshMapForFilters({ scope: next })
- })
- /**
- * 票 14:点位状态筛选变化 → 面板与**一张图**都跟随(决策 02/规格 07 冻结的契约:
- * `map-snapshot` 收多值 `pointStatus`,旧「状态筛选不作用到一张图」的缺口已关闭)。
- * 状态栏点选 → 这里换新快照重载(自动分批到完成)+ 运行监测面板跟随。
- */
- watch(pointStatus, (next) => {
- if (!lifecycleReady.value) return
- monitoringStore.loadPanels({ pointStatus: next })
- refreshMapForFilters({ pointStatus: next })
- })
- /**
- * 票 37:设备类型筛选变化 → 运行监测面板与**一张图**都跟随。
- *
- * 三个端点(overview/distribution/situation)都收 `deviceType` 入参(services/monitoringApi.js
- * 逐行读过),所以三块一起重取;`map-snapshot` 同样收(票 25 第 3 节冻结的路径)。
- * 切模式不重置(deviceType 是 filterStore 的全局条件,与行业/片区同层)。
- */
- watch(deviceType, (next) => {
- if (!lifecycleReady.value) return
- monitoringStore.loadPanels({ deviceType: next })
- refreshMapForFilters({ deviceType: next })
- })
- /** 把筛选条件下给地图并**换新快照重载**(票 08 的快照一致性:一次加载只对应一份数据)。 */
- function refreshMapForFilters(next) {
- mapStore
- .applyFilters(next)
- .then(() => detailStore.syncMapSnapshotSection())
- }
- onBeforeUnmount(() => {
- if (clockTimer) clearInterval(clockTimer)
- if (homeTimer) clearInterval(homeTimer)
- // 票 41:锚定跟随的帧订阅与尺寸监听必须拆掉(与票 15/37 的"漏取消定时器"是同一个坑)。
- if (stopMapFrame) {
- stopMapFrame()
- stopMapFrame = null
- }
- if (stopCameraState) {
- stopCameraState()
- stopCameraState = null
- }
- // 票 41:DEV 把手随外壳一起摘掉(与 mapSummary 把手同款)。
- publishPointSelectionHandle(null)
- // 票 43:设备把手一并摘掉。
- publishDeviceSelectionHandle(null)
- // 票 42:设备图层的四个监听一并拆掉(票 15/37 的"漏取消"是同一个坑)。
- for (const stop of stopDeviceWatches) stop()
- stopDeviceWatches.length = 0
- deviceLayerStore.reset()
- if (anchorResizeHandler) {
- window.removeEventListener('resize', anchorResizeHandler)
- anchorResizeHandler = null
- }
- lastAnchorKey = ''
- // 票 37:闲置计时器与全局交互监听必须拆掉 —— 与票 15 的"漏取消定时器"是同一个坑。
- if (idleTimer) clearInterval(idleTimer)
- for (const domEvent of ['pointerdown', 'pointermove', 'wheel', 'keydown']) {
- window.removeEventListener(domEvent, onAnyActivity, { capture: true })
- }
- setGlobeSpin(0)
- // 票 15:连接是模块级单例,离开外壳必须停掉(否则重连定时器会跟着页面跑下去)。
- runtimeStore.stop()
- // 票 16:刷新周期的定时器同理必须停(票 15 刚在"漏取消定时器"上栽过一次)。
- runtimeStore.stopFreshness()
- // 票 38:定位反馈的自动收起定时器同理必须拆——离开外壳后它不该再去动 store 状态。
- if (locateDismissTimer) {
- window.clearTimeout(locateDismissTimer)
- locateDismissTimer = null
- }
- })
- // ---- 票 25:状态面板里的地图信息读数 ----
- // 计数一律取自**同一份模式图层计划**(`modeLayerPlan`),不另开一份真相;
- // 署名与底图措辞取自 mapService(与真正建图层用的是同一批计划)。
- const mapMarkerCount = computed(() => modeLayerPlan.value.markers.length)
- const mapMarkerStatusText = computed(() => {
- const byStatus = modeLayerPlan.value.byStatus || {}
- const keys = Object.keys(byStatus).sort()
- return keys.length ? keys.map((key) => `${key} ${byStatus[key]}`).join(' · ') : '等待分页数据'
- })
- const mapCoordinateOverview = computed(() => mapStore.coordinate)
- const mapBasemapLabel = computed(() => getBasemapLabel())
- const mapDataSourceCredits = computed(() => getDataSourceCredits())
- </script>
- <style scoped>
- /* 组件内自带的盒模型重置(原型图靠全局 `*` 重置,我们这里不依赖全局样式)。
- 缺了它,铺满容器的外壳加上 padding 会撑出滚动条。 */
- .cockpit-shell,
- .cockpit-shell *,
- .cockpit-shell *::before,
- .cockpit-shell *::after {
- box-sizing: border-box;
- }
- /* ============================================================
- 骨架尺寸
- 主题令牌(颜色 / 字号 / 间距 / 层级)集中在 styles/tokens.css,
- 本文件不再声明;--u 是「设计像素」:1920 宽时为 1px,随视口线性放大、
- 封顶 2px(4K 时正好一倍),小于 1920 时不再缩小。这样 4K 大屏不显空旷,
- 同时页面仍是铺满而非等比留边。
- ============================================================ */
- /* 填满**所在容器**,而不是按 100vh 铺(票 28):宿主布局在顶部留了页头时(`npm run pre.dev`
- 的 TvT 演示站是 54px 固定页头;将来 qiankun 主应用也可能套壳),100vh 会把外壳顶出容器
- 底部——底部内容被裁、容器还多出一条纵向滚动条。改成 absolute inset:0 贴着最近的定位容器,
- 容器多高就多高;窗口 resize 由 CSS 自己跟随,不需要挂载时量一次。 */
- .cockpit-shell {
- position: absolute;
- inset: 0;
- display: grid;
- /* 顶栏 / 筛选条 / 主体:两条横栏压矮,把高度让给真正展示信息的主体 */
- grid-template-rows: calc(52 * var(--u)) calc(34 * var(--u)) minmax(0, 1fr);
- gap: calc(4 * var(--u));
- overflow: hidden;
- padding: calc(8 * var(--u)) calc(12 * var(--u)) calc(12 * var(--u));
- color: var(--text);
- font: var(--fs-body) / 1.45 var(--font);
- }
- /* 全局氛围底:径向辉光 + 细网格(原型 body::before / ::after 的等价实现)。
- 用 absolute 而不是 fixed:随外壳自己的范围铺,宿主页头那一带不归驾驶舱画。 */
- .cockpit-shell__atmosphere {
- position: absolute;
- inset: 0;
- z-index: 0;
- pointer-events: none;
- background:
- radial-gradient(1500px 760px at 50% -12%, rgba(0, 82, 168, 0.4), transparent 62%),
- radial-gradient(1000px 620px at 4% 108%, rgba(0, 82, 168, 0.2), transparent 62%),
- radial-gradient(1000px 620px at 96% 108%, rgba(0, 82, 168, 0.2), transparent 62%),
- radial-gradient(760px 460px at 50% 46%, rgba(0, 61, 133, 0.3), transparent 70%),
- linear-gradient(180deg, var(--bg-0, #010514) 0%, var(--bg-1, #02091b) 42%, var(--bg-0, #010514) 100%);
- }
- .cockpit-shell__atmosphere::after {
- content: "";
- position: absolute;
- inset: 0;
- opacity: 0.5;
- background-image:
- linear-gradient(rgba(1, 171, 234, 0.045) 1px, transparent 1px),
- linear-gradient(90deg, rgba(1, 171, 234, 0.045) 1px, transparent 1px);
- background-size: 56px 56px, 56px 56px;
- mask-image: radial-gradient(120% 90% at 50% 30%, #000 30%, transparent 88%);
- }
- /* 只给真正的网格项加定位;不要用 `.cockpit-shell > *`,
- 否则会以同权重覆盖 .cockpit-shell__atmosphere 的 position: fixed。 */
- .cockpit-shell__topbar,
- .cockpit-shell__scope,
- .cockpit-shell__main {
- position: relative;
- z-index: 1;
- min-width: 0;
- }
- /* ============================================================
- 顶栏(压矮:56 设计像素)
- ============================================================ */
- /* 顶栏三列:品牌 / 一级模式 / 状态区。
- 票 37:右侧状态区改成 `minmax(min-content, 1fr)`,**不再让它被挤到溢出**。
- 原先写死 `minmax(240px, 1fr)`,`justify-content: flex-end` 在内容超宽时会**向左溢出**到
- 模式按钮那一列上 —— 票 37 加了一个「返回全球」chip 后正好越界 46px,
- 实测「归位」压住「预警处置」(x 855–897 vs 799–898)。
- 改成按内容最小宽度兜底后,列宽随内容长,两个 chip 再多也不会压到模式区。 */
- .cockpit-shell__topbar {
- display: grid;
- grid-template-columns: minmax(240px, 1fr) auto minmax(min-content, 1fr);
- align-items: center;
- gap: calc(12 * var(--u));
- height: calc(52 * var(--u));
- /* 票 17 第八轮 R2:基线(标题光带最低线 = path 最低线,即顶栏底)之上留 10u 保护区——
- 内容实际可用高度 42u,按钮行(34u)在其中垂直居中,底边离基线 4u 出血,
- **任何文字与按钮不得压到基线**。时钟 var(--num) 显式声明优先,不受继承影响。
- 票 17 第九轮 R3:min-width: 0 —— 窄视口下让品牌列可收缩(标题切图 max-width 让位),
- 避免 grid 把中列菜单挤到换行(按钮 nowrap 后溢出裁切)。 */
- min-width: 0;
- padding-bottom: calc(10 * var(--u));
- font-family: var(--font-topbar);
- }
- .cockpit-shell__topbar-bg {
- position: absolute;
- inset: 0;
- pointer-events: none;
- background:
- linear-gradient(180deg, rgba(0, 31, 92, 0.58) 0%, rgba(2, 9, 27, 0.12) 62%, transparent 100%),
- radial-gradient(560px 92px at 50% 6%, rgba(1, 171, 234, 0.4), transparent 72%);
- clip-path: polygon(0 0, 100% 0, 100% 74%, calc(100% - 46px) 74%, calc(100% - 62px) 100%, 62px 100%, 46px 74%, 0 74%);
- }
- /* 票 17 第十一轮:底边光路 = 裁切窗口。窗口从标题右侧(430u)铺到屏幕右缘、贴基线;
- 背景把切图放大到 230% 窗口宽(画布列 22.9%–62% 的可见线段恰铺满窗口),
- position 38.4% 100% = 线段左端对窗口左缘、亮线贴窗口底(=基线,与标题光带共线)。
- 第九轮的 right-shift 方案失败原因:整图拉伸 100% 宽时线段按百分比映射视口,
- 挪图片盒子改变不了线段落在标题上的事实。 */
- /* 票 17 第十二轮:窗口左缘 430u → 340u——线段起点钻进标题光带尾部 ~90u,
- 营造"光线从标题延伸出来"的衔接感;仍避开文字带(文字带右缘 ≈12%+75%×404 ≈ 315u)。 */
- .cockpit-shell__topbar-path {
- position: absolute;
- left: calc(340 * var(--u));
- right: 0;
- bottom: 0;
- height: calc(19 * var(--u));
- background-image: var(--brand-path-url, none);
- background-repeat: no-repeat;
- background-size: 230% 100%;
- background-position: 38.4% 100%;
- pointer-events: none;
- }
- /* 票 17 第八轮 R2:两根背景装饰线挪出文字区——
- 原来固定在 top:50% 且横跨 50%±400px,正好横穿菜单文字。
- 现改为:垂直方向贴顶栏底部(top: 58%,在基线上方、不越过),水平方向
- 一根贴最左(品牌区左侧空白)、一根贴最右(状态区右侧空白),不进文字带。 */
- .cockpit-shell__wing {
- position: absolute;
- top: 58%;
- width: calc(170 * var(--u));
- height: 2px;
- pointer-events: none;
- background: linear-gradient(90deg, transparent, rgba(1, 171, 234, 0.75));
- box-shadow: 0 0 12px rgba(1, 171, 234, 0.55);
- opacity: 0.55;
- }
- .cockpit-shell__wing::after {
- content: "";
- position: absolute;
- right: 0;
- top: -3px;
- width: 8px;
- height: 8px;
- background: var(--cyan);
- transform: rotate(45deg);
- box-shadow: 0 0 12px var(--cyan);
- }
- .cockpit-shell__wing.is-left { left: 0; }
- .cockpit-shell__wing.is-right {
- right: 0;
- transform: scaleX(-1);
- }
- /* 票 17 第十一轮(结构性根因修复):品牌列**定高 + 绝对定位标题**。
- 此前标题切图(高 98u)是 grid 项内容,把顶栏整行撑到 98u——菜单按钮"垂直居中"
- 是相对 98u 行居中的,底边正好落在基线上(宽屏菜单"串在底线上"的真因;
- 窄屏切图被 max-width 缩小、行变矮,菜单反而正常——与维护者观察完全一致)。
- 现在:品牌列 height 52u + overflow visible,标题绝对定位溢出显示,
- grid 行高恢复 52u,菜单/状态区真正在 42u 内容区里居中。 */
- .cockpit-shell__brand {
- position: relative;
- height: calc(52 * var(--u));
- min-width: 0;
- padding-left: calc(6 * var(--u));
- }
- /* 标题切图(brand-title.png 940×228):绝对定位于品牌列、顶部对齐。
- 宽 404u → 高 98u:亮线(画布 53.1%)= 顶栏底 52u,与底边光路共线;
- 文字带 ≈53u 在亮线上。溢出顶栏的淡晕不占布局,由下方筛选条承接。
- 窄视口让位:max-width 以品牌列宽为上限(min-width:0 生效后 = 可用宽),等比缩。 */
- .cockpit-shell__mark {
- position: absolute;
- top: 0;
- left: calc(6 * var(--u));
- max-width: calc(100% - 12 * var(--u));
- height: auto;
- }
- /* 票 18(批次 5):品牌标题后方光池(蓝湖 D6「光」层,设计 realFrame 489×128 @ (-19,-1),
- 即相对品牌标题左上偏移 -19,-1——左缘超出标题 19/489 ≈ 3.9%,上缘超 1 设计像素)。
- 复杂位图光效不可 CSS 无损复现(规格 03 判据),按设计 frame 铺在标题下层:
- 锚点与标题切图同(left 6u),宽 = 标题 404u × 489/470 ≈ 1.0404 倍,
- translateX(-3.885%) 抵消设计稿左偏(百分比对自身宽,即 -19/489)。
- max-width 随标题的让位上限同步放大 1.0404 倍,窄视口一起缩。
- 纯装饰(aria-hidden 层内的 ::before)、pointer-events:none,不参与拾取与命名;
- manifest 缺条目时 --brand-pool-url 为 none,自动不画。 */
- .cockpit-shell__brand::before {
- content: '';
- position: absolute;
- top: calc(-1 * var(--u));
- left: calc(6 * var(--u));
- width: calc(404 * var(--u) * 1.0404);
- max-width: calc((100% - 12 * var(--u)) * 1.0404);
- aspect-ratio: 489 / 128;
- transform: translateX(-3.885%);
- background-image: var(--brand-pool-url, none);
- background-repeat: no-repeat;
- background-size: 100% 100%;
- pointer-events: none;
- }
- /* 视觉隐藏但保留给读屏与走查断言(切图 aria-hidden,可访问名靠这里)。 */
- .cockpit-shell__sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- white-space: nowrap;
- }
- .cockpit-shell__modes {
- display: flex;
- gap: calc(4 * var(--u));
- justify-content: center;
- position: relative;
- }
- .cockpit-shell__mode {
- position: relative;
- height: calc(34 * var(--u));
- padding: 0 calc(17 * var(--u));
- border: 0;
- /* 票 17 第七轮(按蓝湖 D2 nav 取证重做):设计稿里菜单就是「纯文字 + 选中态底板」——
- 未选中 #00b8ff 常规字重(PingFang 14px,Apple 系统字体走回退栈),选中白色 + Semibold,
- 底板 = `nav/title-bg` 切图(顶部青色亮线 + 向下衰减浅蓝板,90×34@2x)。
- 此前的切角梯形 + 深蓝渐变 + 下划线 + 无界黑均非设计稿所有,全部撤除。
- 票 17 第九轮 R3:white-space: nowrap —— 窄视口下 grid 挤压中列会让文字换行、
- 34u 定高裁掉第二行(实测只剩"总/基/运"单字);菜单文字永不换行,
- 顶栏三列改为允许纵向压缩而不是挤压按钮。 */
- color: var(--cyan-hi);
- font-size: var(--fs-body, 14px);
- white-space: nowrap;
- background: transparent;
- cursor: pointer;
- transition: color 0.18s, filter 0.18s;
- }
- .cockpit-shell__mode:hover {
- color: var(--text-strong);
- filter: brightness(1.15);
- }
- .cockpit-shell__mode:focus-visible {
- outline: 1px solid var(--cyan);
- outline-offset: 2px;
- }
- .cockpit-shell__mode.is-active {
- color: var(--text-strong);
- font-weight: 600;
- /* 底板切图经语义 key 注入(--nav-active-bg,见模板 header 绑定);素材缺失时退化为
- 设计稿主蓝的极淡渐变,选中态仍可辨。 */
- background-image: var(--nav-active-bg, none),
- linear-gradient(180deg, rgba(1, 171, 234, 0.28), transparent);
- background-repeat: no-repeat;
- background-size: 100% 100%;
- }
- /* 票 17 第七轮:选中按钮下方的光晕(设计稿 D2 `光 x=906 y=49 w=221 h=35`——实测中心 x=1016
- 正对选中按钮「预警处置」中心 x=1017,即**光带跟着选中按钮走**,不是挂在 nav 中间)。
- 票 17 第八轮:光晕主体(亮线在图片 44%)随基线上移——bottom 对齐基线上方 4u,
- 光晕亮线压在按钮文字底下而不越过基线(R2:随 10u 保护区微调,top 100%−8u)。 */
- .cockpit-shell__mode.is-active::after {
- content: "";
- position: absolute;
- top: calc(100% - 8 * var(--u));
- left: 50%;
- transform: translateX(-50%);
- width: calc(221 * var(--u));
- height: calc(35 * var(--u));
- background-image: var(--nav-glow-url, none);
- background-repeat: no-repeat;
- background-position: center;
- background-size: contain;
- pointer-events: none;
- }
- .cockpit-shell__top-meta {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- gap: calc(10 * var(--u));
- padding-right: calc(8 * var(--u));
- color: var(--text-2);
- font-size: var(--fs-body, 14px);
- white-space: nowrap;
- }
- .cockpit-shell__clock {
- font-family: var(--num);
- font-size: var(--fs-body, 14px);
- letter-spacing: 0.06em;
- color: var(--text-strong);
- text-shadow: 0 0 12px rgba(1, 171, 234, 0.55);
- }
- .cockpit-shell__meta {
- margin: 0;
- color: var(--muted);
- font-size: var(--fs-caption, 11.5px);
- letter-spacing: 0.06em;
- }
- /* ============================================================
- 全局筛选条(压矮:38 设计像素)
- ============================================================ */
- .cockpit-shell__scope {
- z-index: 3;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: calc(10 * var(--u));
- overflow: visible;
- padding: 0 calc(10 * var(--u));
- border: 1px solid rgba(1, 171, 234, 0.24);
- background: linear-gradient(180deg, rgba(0, 31, 92, 0.72), rgba(0, 15, 52, 0.88));
- box-shadow: inset 0 1px 0 rgba(1, 171, 234, 0.1), var(--shadow);
- /* 票 17:第二行筛选条与顶栏同属"顶部菜单栏",字体保持一致(系统黑体),
- 否则两行按钮字体一细一粗更难看。此处全为中文标签,无 --num 数字依赖。 */
- font-family: var(--font-topbar);
- }
- .cockpit-shell__scope-left,
- .cockpit-shell__scope-right {
- display: flex;
- align-items: center;
- gap: calc(6 * var(--u));
- white-space: nowrap;
- min-width: 0;
- }
- .cockpit-shell__scope-left {
- flex: 1 1 auto;
- overflow-x: auto;
- overflow-y: hidden;
- scrollbar-width: none;
- }
- .cockpit-shell__scope-left::-webkit-scrollbar {
- display: none;
- }
- .cockpit-shell__scope-right {
- flex: 0 1 auto;
- justify-content: flex-end;
- max-width: 46%;
- }
- .cockpit-shell__scope-label {
- color: var(--muted);
- font-size: var(--fs-caption, 11.5px);
- letter-spacing: 0.08em;
- }
- .cockpit-shell__chips {
- display: flex;
- gap: calc(4 * var(--u));
- }
- .cockpit-shell__divider {
- width: 1px;
- height: calc(16 * var(--u));
- margin: 0 calc(3 * var(--u));
- background: linear-gradient(180deg, transparent, rgba(1, 171, 234, 0.5), transparent);
- }
- /* 范围 / 行业按钮:作用于整个驾驶舱的全局条件(切模式不重置) */
- .cockpit-shell__chip {
- display: inline-flex;
- align-items: center;
- padding: calc(2 * var(--u)) calc(9 * var(--u));
- border: 1px solid rgba(1, 171, 234, 0.32);
- background: rgba(0, 20, 72, 0.6);
- color: var(--text-2);
- font: inherit;
- /* 2026-10-12:走查反馈偏小,由 10.5 上调到 11.5。 */
- font-size: var(--fs-caption, 11.5px);
- letter-spacing: 0.04em;
- white-space: nowrap;
- clip-path: polygon(4px 0, 100% 0, 100% calc(100% - 4px), calc(100% - 4px) 100%, 0 100%, 0 4px);
- cursor: pointer;
- transition: all 0.16s;
- }
- /* 批次 1:行业筛选按钮的图标。用 margin 而非 gap —— 只有带图标的按钮才需要间距,
- 不改通用 chip 的排版,避免影响范围/视图等其他同款按钮。 */
- .cockpit-shell__industry-icon {
- flex: none;
- margin-right: calc(4 * var(--u));
- text-shadow: none;
- }
- .cockpit-shell__chip:hover {
- color: var(--text-strong);
- border-color: rgba(1, 171, 234, 0.6);
- background: rgba(0, 82, 168, 0.62);
- }
- .cockpit-shell__chip:focus-visible {
- outline: 1px solid var(--cyan);
- outline-offset: 2px;
- }
- .cockpit-shell__chip.is-active {
- color: var(--text-strong);
- border-color: rgba(0, 184, 255, 0.85);
- background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
- box-shadow: inset 0 0 16px rgba(1, 171, 234, 0.35), 0 0 14px rgba(1, 171, 234, 0.35);
- text-shadow: 0 0 10px rgba(180, 240, 255, 0.9);
- }
- .cockpit-shell__scope-note {
- color: var(--muted);
- font-size: var(--fs-caption, 11.5px);
- letter-spacing: 0.04em;
- }
- /* 票 08:地图摘要仍是状态详情的一行,收起时由上层摘要按钮替代其横向占位。 */
- .cockpit-shell__map-summary {
- display: flex;
- align-items: center;
- gap: calc(5 * var(--u));
- min-width: 0;
- overflow: hidden;
- }
- .cockpit-shell__status-summary {
- position: relative;
- flex: 0 1 auto;
- min-width: min(220px, 28vw);
- max-width: min(520px, 42vw);
- }
- .cockpit-shell__status-toggle {
- display: flex;
- align-items: center;
- gap: calc(6 * var(--u));
- width: 100%;
- min-height: calc(28 * var(--u));
- padding: calc(3 * var(--u)) calc(8 * var(--u));
- border: 1px solid rgba(1, 171, 234, 0.32);
- background: rgba(0, 20, 72, 0.72);
- color: var(--text-2);
- font: inherit;
- font-size: var(--fs-caption, 11.5px);
- text-align: left;
- cursor: pointer;
- clip-path: polygon(5px 0, 100% 0, 100% calc(100% - 5px), calc(100% - 5px) 100%, 0 100%, 0 5px);
- }
- .cockpit-shell__status-toggle:hover,
- .cockpit-shell__status-toggle:focus-visible {
- border-color: rgba(1, 171, 234, 0.75);
- color: var(--text-strong);
- outline: none;
- }
- .cockpit-shell__status-toggle [data-status-summary] {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .cockpit-shell__status-dot {
- width: calc(7 * var(--u));
- height: calc(7 * var(--u));
- flex: none;
- border-radius: 50%;
- background: var(--muted, #5680b0);
- box-shadow: 0 0 8px currentColor;
- }
- .cockpit-shell__status-dot.is-ok {
- background: var(--lv4, #27d17a);
- color: var(--lv4, #27d17a);
- }
- .cockpit-shell__status-dot.is-warning {
- background: var(--lv2, #ffb020);
- color: var(--lv2, #ffb020);
- }
- .cockpit-shell__status-chevron {
- color: var(--cyan, #01abea);
- font-size: var(--fs-body, 14px);
- line-height: 1;
- }
- .cockpit-shell__status-details {
- position: absolute;
- z-index: 20;
- top: calc(100% + 5 * var(--u));
- right: 0;
- display: grid;
- gap: calc(6 * var(--u));
- width: min(560px, 48vw);
- max-width: calc(100vw - 24 * var(--u));
- max-height: min(62vh, 520px);
- /* 2026-10-13:严格只竖向滚动(状态详情浮层同理)。 */
- overflow-y: auto;
- overflow-x: hidden;
- padding: calc(9 * var(--u));
- border: 1px solid rgba(1, 171, 234, 0.42);
- background: rgba(1, 5, 20, 0.96);
- box-shadow: 0 14px 32px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(130, 230, 255, 0.1);
- }
- .cockpit-shell__status-details > * {
- min-width: 0;
- }
- .cockpit-shell__refresh-action {
- flex: none;
- min-width: calc(58 * var(--u));
- justify-content: center;
- gap: calc(5 * var(--u));
- }
- .cockpit-shell__refresh-glyph {
- font-size: var(--fs-title, 15.5px);
- line-height: 1;
- }
- /* 状态点:加载中呼吸、完成转绿、失败/过期转红 —— 颜色语义与小胶囊共存。 */
- .cockpit-shell__map-summary-dot {
- flex: none;
- width: calc(6 * var(--u));
- height: calc(6 * var(--u));
- border-radius: 50%;
- background: var(--lv2, #ffb020);
- animation: map-summary-breathe 1.8s ease-in-out infinite;
- }
- .cockpit-shell__map-summary[data-map-summary-phase='complete'] .cockpit-shell__map-summary-dot {
- background: var(--lv4, #27d17a);
- animation: none;
- }
- .cockpit-shell__map-summary[data-map-summary-phase='failed'] .cockpit-shell__map-summary-dot,
- .cockpit-shell__map-summary[data-map-summary-phase='expired'] .cockpit-shell__map-summary-dot {
- background: var(--lv1, #ff3b52);
- animation: none;
- }
- @keyframes map-summary-breathe {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.3; }
- }
- .cockpit-shell__map-summary-text {
- max-width: calc(150 * var(--u));
- overflow: hidden;
- color: var(--text-2);
- font-size: var(--fs-caption, 11.5px);
- letter-spacing: 0.03em;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- /* 加载中 / 快照失效时给状态词换色,让「还能不能继续取」一眼可辨。 */
- .cockpit-shell__map-summary[data-map-summary-phase='expired'] .cockpit-shell__map-summary-text,
- .cockpit-shell__map-summary[data-map-summary-phase='failed'] .cockpit-shell__map-summary-text {
- color: var(--lv1);
- }
- .cockpit-shell__map-summary[data-map-summary-phase='complete'] .cockpit-shell__map-summary-text {
- color: var(--lv4);
- }
- /* 票 05:共享分区状态板。横向排列、可换行;空间不够时先压这一块,不去挤筛选按钮。 */
- .cockpit-shell__sections {
- display: flex;
- align-items: center;
- gap: calc(4 * var(--u));
- flex-wrap: nowrap;
- /* 票 08:显式允许收缩——筛选条在 1280 宽时靠它和地图摘要块一起让位。 */
- flex-shrink: 1;
- min-width: 0;
- overflow: hidden;
- }
- /* 票 17:定位失败反馈。用范围色(不是错误色)——「不在已加载范围内」是提示,不是故障。 */
- .cockpit-shell__locate {
- color: var(--lv2, #ffb020);
- border-color: rgba(255, 176, 32, 0.5);
- }
- /*
- 票 38(第 5 条):定位失败的可见反馈浮层。挂在 `<main>` 底部居中——
- 两张面板栏之间、地图之上,不随「状态详情」的折叠而消失(那正是「点了没反应」的来源)。
- pointer-events 只给关闭按钮以外的胶囊本体与边框(浮层不该挡地图操作)。
- */
- .cockpit-shell__notice {
- position: absolute;
- z-index: 6;
- bottom: calc(14 * var(--u));
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- align-items: center;
- gap: calc(6 * var(--u));
- max-width: min(72%, calc(100% - 2 * var(--column-w)));
- pointer-events: none;
- }
- .cockpit-shell__notice-chip {
- min-width: 0;
- padding: calc(4 * var(--u)) var(--space-2);
- border: 1px solid rgba(255, 176, 32, 0.5);
- border-radius: calc(8 * var(--u));
- background: rgba(2, 10, 20, 0.88);
- box-shadow: var(--shadow);
- font-family: var(--font-topbar);
- font-size: var(--fs-caption, 11.5px);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .cockpit-shell__notice-close {
- pointer-events: auto;
- padding: calc(3 * var(--u)) calc(8 * var(--u));
- border: 1px solid rgba(1, 171, 234, 0.4);
- border-radius: calc(8 * var(--u));
- background: rgba(2, 10, 20, 0.88);
- color: var(--text-2, #88b7e8);
- font-family: var(--font-topbar);
- font-size: var(--fs-caption, 11.5px);
- cursor: pointer;
- }
- .cockpit-shell__notice-close:hover {
- color: var(--text-strong);
- border-color: rgba(1, 171, 234, 0.7);
- }
- /* 票 15:状态推送连接状态。连上时是常态色(正常运行),未连上/鉴权失败转告警色。 */
- .cockpit-shell__ws {
- color: var(--muted, #5680b0);
- }
- /* 票 38:自建雾读数。生效/待命用常态色;不可用或降级转注意色(**不使用错误色** ——
- 雾不可用属"能力降级、业务照常",不是故障;与票 05 的分区状态同一条立场)。 */
- .cockpit-shell__fog {
- color: var(--muted, #5680b0);
- }
- .cockpit-shell__fog[data-fog-state='ready'][data-fog-phase='locked'] {
- color: var(--lv3, #01abea);
- border-color: rgba(1, 171, 234, 0.4);
- }
- .cockpit-shell__fog[data-fog-degrade='reduced'],
- .cockpit-shell__fog[data-fog-degrade='off'],
- .cockpit-shell__fog.is-bad {
- color: var(--lv2, #ffb020);
- border-color: rgba(255, 176, 32, 0.45);
- }
- .cockpit-shell__ws[data-ws-state='open'] {
- color: var(--lv4, #27d17a);
- border-color: rgba(53, 227, 154, 0.4);
- }
- .cockpit-shell__ws.is-bad {
- color: var(--lv1, #ff3b52);
- border-color: rgba(255, 59, 82, 0.45);
- }
- /* 票 16:新鲜度。新鲜=常态色;**过期必须显眼**(保留旧值但不能被当成实时);不可用=告警色。 */
- .cockpit-shell__freshness[data-freshness='current'] {
- color: var(--lv4, #27d17a);
- }
- .cockpit-shell__freshness[data-freshness='empty'] {
- color: var(--muted, #5680b0);
- }
- .cockpit-shell__freshness.is-stale {
- color: var(--lv2, #ffb020);
- border-color: rgba(255, 176, 32, 0.55);
- }
- .cockpit-shell__freshness.is-bad {
- color: var(--lv1, #ff3b52);
- border-color: rgba(255, 59, 82, 0.45);
- }
- /* 「部分失败」的补充说明:同一枚胶囊里再补一句,不另起一块浮层压住一张图。 */
- .cockpit-shell__freshness-extra {
- color: var(--lv2, #ffb020);
- font-weight: 700;
- }
- /* ============================================================
- 主体:一张图铺满背景,左右两栏浮在图上
- ============================================================ */
- .cockpit-shell__main {
- position: relative;
- min-height: 0;
- }
- .cockpit-shell__map {
- position: absolute;
- inset: 0;
- z-index: var(--z-map);
- }
- .cockpit-shell__column {
- position: absolute;
- top: 0;
- bottom: 0;
- z-index: var(--z-columns);
- display: flex;
- flex-direction: column;
- gap: calc(8 * var(--u));
- /* 栏宽是共享的布局尺寸:两级详情浮层要贴着它让位(见 styles/tokens.css 的 --column-w)。 */
- width: var(--column-w);
- min-height: 0;
- pointer-events: none;
- }
- .cockpit-shell__column > * {
- pointer-events: auto;
- }
- .cockpit-shell__column.is-left { left: 0; }
- .cockpit-shell__column.is-right { right: 0; }
- /* 票 10:两级详情浮层。贴在两栏之间的空档里、右栏左侧,
- 宽度与栏同量级;高度由内容决定、超长时卡片自己滚动。
- 2026-10-13 维护者反馈设备详情面板太窄:340→380u(上限 420→460px)。 */
- .cockpit-shell__detail {
- position: absolute;
- z-index: var(--z-columns);
- top: 0;
- right: calc(var(--column-w) + var(--space-2));
- width: min(calc(100% - 24px), clamp(420px, calc(480 * var(--u)), 560px));
- max-height: 100%;
- min-height: 0;
- display: flex;
- flex-direction: column;
- pointer-events: auto;
- }
- /* 票 41:锚定形态。层级**低于**四个详情窗(R-3:允许被侧栏遮盖),
- 只避开画布边缘、允许被画布裁切(R-2),唯一隐藏条件是窗口完全离开画面(R-4)。 */
- .cockpit-shell__detail.is-anchored {
- z-index: 1;
- right: auto;
- /* 内容不足时按内容高(不留空白);超长时由 JS 钉住高度、卡内滚动接管。 */
- max-height: min(80%, 100%);
- /* 允许被画布边缘裁切:不因此整体消失(规格 R-2)。 */
- overflow: hidden;
- border: 1px solid var(--panel-line-hi, #01abea);
- background: linear-gradient(180deg, var(--panel-top, rgba(2, 9, 27, 0.28)) 0%, var(--panel-bot, rgba(0, 15, 52, 0.38)) 100%);
- box-shadow: var(--shadow, 0 18px 44px rgba(0, 0, 0, 0.55));
- }
- /* 归属短线:从图标外缘指向卡片,`transform-origin` 在左端,长度由 JS 给。 */
- .cockpit-shell__anchor-lead {
- position: absolute;
- z-index: 1;
- height: 1px;
- transform-origin: 0 50%;
- background: linear-gradient(90deg, rgba(1, 171, 234, 0.85), rgba(1, 171, 234, 0.25));
- box-shadow: 0 0 6px rgba(1, 171, 234, 0.5);
- pointer-events: none;
- }
- /* 暂隐时的顶栏入口:与其它 chip 同形,动作按钮收在文字后面。 */
- .cockpit-shell__anchor-status {
- color: var(--lv2, #ffb020);
- border-color: rgba(255, 176, 32, 0.5);
- display: inline-flex;
- align-items: center;
- gap: calc(4 * var(--u));
- }
- .cockpit-shell__anchor-status button {
- padding: 0 calc(4 * var(--u));
- border: 1px solid rgba(255, 176, 32, 0.5);
- background: rgba(0, 20, 72, 0.6);
- color: inherit;
- font: inherit;
- cursor: pointer;
- }
- .cockpit-shell__anchor-status button:hover,
- .cockpit-shell__anchor-status button:focus-visible {
- color: var(--text-strong);
- border-color: rgba(255, 176, 32, 0.9);
- outline: none;
- }
- .cockpit-shell__event-detail {
- position: absolute;
- z-index: 5;
- top: 24px;
- left: calc(var(--column-w) + 24 * var(--u));
- width: min(480px, calc(100% - var(--column-w) - 48 * var(--u)));
- max-height: calc(100% - 48px);
- overflow-y: auto;
- overflow-x: hidden;
- border: 1px solid var(--cyan);
- background: linear-gradient(var(--panel-top), var(--panel-bot)), var(--bg-0);
- box-shadow: var(--shadow);
- }
- </style>
|