소스 검색

票24:管线/设施图层接线(mapService 桥接 + MapCanvas 挂载);含并发会话票25状态面板重构

Kazerin 1 주 전
부모
커밋
3c698142ee

+ 8 - 0
.gitignore

@@ -29,3 +29,11 @@ pluginMaker/install
 
 # 驾驶舱部署配置(票 04):各环境真实地址与只读令牌不入库,模板见 public/cockpit/config.example.json
 public/cockpit/config.json
+node_modules
+
+# 后端校验副本(票 29/30 的隔离工作树,内含嵌套 .git,不入库)
+.scratch/*/implementation/*/push-checkout/
+.scratch/*/implementation/*/validation-checkout/
+
+# 编辑器临时备份
+*~RF*.TMP

+ 64 - 4
src/modules/cockpit/CockpitShell.vue

@@ -111,7 +111,7 @@
                 <span class="cockpit-shell__scope-label">行业</span>
                 <div class="cockpit-shell__chips">
                     <button
-                        v-for="item in INDUSTRIES"
+                        v-for="item in industryChips"
                         :key="item.key"
                         type="button"
                         class="cockpit-shell__chip cockpit-shell__industry-btn"
@@ -120,6 +120,8 @@
                         :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>
@@ -206,6 +208,24 @@
                     />
                 </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:定位反馈。点面板里的可定位对象时,只有三种结果值得说话 ——
                     点位不在已加载范围内、后端判定不可上图、地图还没就绪。
@@ -423,7 +443,7 @@ import {
     sectionStateOf,
     viewFromSectionState,
 } from './domain/freshnessModel.js'
-import { INDUSTRIES, SCOPES, industryQuery, districtQuery } from './domain/filterOptions.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'
@@ -431,6 +451,8 @@ import { usePipeNetworkStore } from './stores/pipeNetworkStore.js'
 import CockpitPanel from './components/CockpitPanel.vue'
 import CockpitIcon from './components/CockpitIcon.vue'
 import MapCanvas from './components/MapCanvas.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'
@@ -475,6 +497,9 @@ import {
     publishDeviceSelectionHandle,
     setPanelSelectionLevel,
     getIntroConfig,
+    // 票 25:底图措辞与数据来源署名(Cesium 署名已移出画面,改由状态面板承担)。
+    getBasemapLabel,
+    getDataSourceCredits,
     goHome,
     resetToIntroView,
     setAreaState,
@@ -501,6 +526,14 @@ const statusPanelExpanded = ref(false)
 const { mode, expandedPanelIds } = storeToRefs(uiStore)
 const { scope, industry, viewMode, pointStatus, areaState } = storeToRefs(filterStore)
 
+/**
+ * 批次 1:行业筛选按钮带上图标(`all` 无图标,渲染时不画)。
+ * 单独派生一份而不是就地改 `INDUSTRIES` —— 筛选契约本身不带视觉信息。
+ */
+const industryChips = computed(() => INDUSTRIES.map((item) => ({ ...item, icon: industryIcon(item.key) })))
+/** 与筛选按钮文字(10.5u)配套的图标边长。 */
+const industryIconSize = 'calc(11 * var(--u, 1px))'
+
 // ---- 四个已接数据的模式(票 17 基础设施 / 票 18 运行监测 / 票 19 预警处置 / 票 20 总体概览)----
 // 16 块面板现已全部接上真实视图;`PanelPlaceholder` 仍是未注册 panelId 的兜底。
 const { sections: infrastructureSections } = storeToRefs(infrastructureStore)
@@ -1568,6 +1601,18 @@ onBeforeUnmount(() => {
     // 票 16:刷新周期的定时器同理必须停(票 15 刚在"漏取消定时器"上栽过一次)。
     runtimeStore.stopFreshness()
 })
+// ---- 票 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>
@@ -1710,17 +1755,24 @@ onBeforeUnmount(() => {
     display: grid;
     place-items: center;
     flex: none;
-    width: calc(32 * var(--u));
-    height: calc(32 * var(--u));
+    /* 批次 1:品牌位换成蓝湖正式切图 `nav/title-bg`(设计稿 90×34)。
+       顶栏宽度紧张,按切图原比例(90:34 ≈ 2.65:1)缩到 50×19,
+       只比原来的 32×32 方块多占 18u,避免把标题挤出可视区。 */
+    width: calc(50 * var(--u));
+    height: calc(19 * var(--u));
     filter: drop-shadow(0 0 10px rgba(79, 216, 255, 0.6));
 }
 
+.cockpit-shell__mark img,
 .cockpit-shell__mark svg {
     width: 100%;
     height: 100%;
+    object-fit: contain;
 }
 
 .cockpit-shell__brand-text {
+    /* 品牌切图占位变宽后,标题不能被压掉(否则「驾驶舱系统」会被模式按钮盖住)。 */
+    flex: none;
     min-width: 0;
 }
 
@@ -1900,6 +1952,14 @@ onBeforeUnmount(() => {
     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: #fff;
     border-color: rgba(79, 216, 255, 0.6);

+ 25 - 8
src/modules/cockpit/components/CockpitIcon.vue

@@ -2,15 +2,26 @@
   图标:按语义名渲染统一资产解析层登记的图标资源。
 
   用法:`<CockpitIcon name="layers" />`,可选 `size`(CSS 长度,默认 1em)。
-  渲染方式是 CSS mask:图标文件只提供形状(alpha),颜色仍由父级 currentColor 控制——
-  与内联 SVG 时代一致,换正式素材不需要动任何颜色配置。
+  两种渲染方式由 manifest 的 `renderMode` 决定,组件接缝(name/size)不变:
+    · image —— 蓝湖正式切图(彩色 PNG):按 `<img>` 渲染,宽度取 size、高度按原比例。
+    · mask  —— 单色遮罩资源(SVG):CSS mask,颜色仍跟随 currentColor。
   未知名称或 manifest 校验失败时渲染为空(不抛错):图标是视觉资源,缺失不应打断骨架渲染。
 -->
 <template>
+    <img
+        v-if="icon && icon.renderMode === 'image'"
+        class="cockpit-icon cockpit-icon--image"
+        :src="icon.url"
+        :style="imageStyle"
+        :data-icon-name="name"
+        :data-asset-status="icon.status"
+        alt=""
+        aria-hidden="true"
+    />
     <span
-        v-if="icon"
-        class="cockpit-icon"
-        :style="iconStyle"
+        v-else-if="icon"
+        class="cockpit-icon cockpit-icon--mask"
+        :style="maskStyle"
         :data-icon-name="name"
         :data-asset-status="icon.status"
         aria-hidden="true"
@@ -28,8 +39,11 @@ const props = defineProps({
 
 const icon = computed(() => iconFor(props.name))
 
-// mask 用 `contain` + 居中,等价于原 SVG 在方形盒内按 viewBox 等比缩放(含 blank 的 48:32)。
-const iconStyle = computed(() =>
+// 彩色切图:宽度取 size,高度按素材自身比例,避免非正方形素材被拉变形。
+const imageStyle = computed(() => (icon.value ? { width: props.size, height: 'auto' } : {}))
+
+// mask 用 `contain` + 居中,等价于原 SVG 在方形盒内按 viewBox 等比缩放。
+const maskStyle = computed(() =>
     icon.value
         ? { width: props.size, height: props.size, '--icon-url': `url("${icon.value.url}")` }
         : {},
@@ -40,8 +54,11 @@ const iconStyle = computed(() =>
 .cockpit-icon {
     display: block;
     flex: none;
+}
+
+.cockpit-icon--mask {
     background-color: currentColor;
     -webkit-mask: var(--icon-url) center / contain no-repeat;
     mask: var(--icon-url) center / contain no-repeat;
 }
-</style>
+</style>

+ 29 - 3
src/modules/cockpit/components/CockpitPanel.vue

@@ -12,7 +12,11 @@
             :aria-expanded="expanded"
             @click="emit('toggle')"
         >
-            <span class="cockpit-panel__title">{{ title }}</span>
+            <span class="cockpit-panel__title">
+                <!-- 批次 1:面板图标按 panelId 取自蓝湖正式切图;纯装饰,aria-hidden 不参与命名。 -->
+                <CockpitIcon v-if="iconName" :name="iconName" :size="panelIconSize" class="cockpit-panel__icon" />
+                {{ title }}
+            </span>
             <small class="cockpit-panel__meta">{{ meta }}</small>
             <span class="cockpit-panel__mark" aria-hidden="true">{{ expanded ? '−' : '+' }}</span>
         </button>
@@ -25,7 +29,11 @@
 </template>
 
 <script setup>
-defineProps({
+import { computed } from 'vue'
+import CockpitIcon from './CockpitIcon.vue'
+import { panelIcon } from '../domain/panelCatalog.js'
+
+const props = defineProps({
     title: { type: String, required: true },
     meta: { type: String, default: '' },
     expanded: { type: Boolean, default: true },
@@ -38,6 +46,12 @@ defineProps({
 })
 
 const emit = defineEmits(['toggle'])
+
+/** 面板图标语义名;无对应图标时为空串 → 不渲染图标,不抛错。 */
+const iconName = computed(() => panelIcon(props.panelId))
+
+/** 与 15.5u 的标题字号配套的图标边长(走查反馈偏小,由 14 上调)。 */
+const panelIconSize = 'calc(17 * var(--u, 1px))'
 </script>
 
 <style scoped>
@@ -143,15 +157,27 @@ const emit = defineEmits(['toggle'])
 .cockpit-panel__title {
     position: relative;
     flex: none;
+    /* 批次 1:标题改为 inline-flex 以容纳面板图标(竖条仍是 ::before 绝对定位,不受影响)。 */
+    display: inline-flex;
+    align-items: center;
+    gap: calc(5 * var(--u, 1px));
     padding-left: calc(10 * var(--u, 1px));
     color: #eaf9ff;
-    font-size: calc(14.5 * var(--u, 1px));
+    /* 2026-10-12:走查反馈偏小,由 14.5 上调到 15.5,与 tokens 的 --fs-title 同步。 */
+    font-size: calc(15.5 * var(--u, 1px));
     font-weight: 700;
     letter-spacing: 0.1em;
     white-space: nowrap;
     text-shadow: 0 0 12px rgba(79, 216, 255, 0.6);
 }
 
+/* 面板图标:单独一档弱辉光,避免继承标题的强发光把小图标糊成一团。 */
+.cockpit-panel__icon {
+    flex: none;
+    text-shadow: none;
+    filter: drop-shadow(0 0 4px rgba(79, 216, 255, 0.45));
+}
+
 .cockpit-panel__title::before {
     content: '';
     position: absolute;

+ 18 - 168
src/modules/cockpit/components/MapCanvas.vue

@@ -44,54 +44,18 @@
                 </button>
             </div>
 
-            <div v-if="uiDebug" class="map-holo__headline">
-                <b>中央“一张图”</b>
-                <div class="map-holo__layers">
-                    <span v-for="layer in LAYERS" :key="layer.label">
-                        <CockpitIcon :name="layer.icon" size="calc(11 * var(--u, 1px))" />
-                        {{ layer.label }}
-                    </span>
-                </div>
-            </div>
-        </div>
-
-        <div class="map-holo__bottom" :class="{ 'is-quiet': !uiDebug }">
-            <p v-if="uiDebug" class="map-canvas__title">中央一张图占位</p>
-            <p v-if="uiDebug" class="map-holo__hint">
-                临时底图 aerial.webp · 票 02 已建常驻宿主(实例 {{ instanceId }})· {{ modeLabel }}
-            </p>
-            <div class="map-holo__status" :class="{ 'is-quiet': !uiDebug }">
-                <i :class="{ 'is-done': summarySettled, 'is-bad': summaryBad }"></i>
-                <span>{{ summaryText }}</span>
-                <!--
-                    票 46:模式图层计划的降级说明(设施数据未就绪 / 管线数据未就绪 /
-                    异常设备数据未就绪)。只在本模式确实受影响时出现,**不伪装成空数据**。
-                -->
-                <span
-                    v-for="note in modePlan.degradedNotes"
-                    :key="note.key"
-                    class="map-holo__degraded"
-                    :data-map-degraded-note="note.key"
-                    role="status"
-                >{{ note.text }}</span>
-                <!--
-                    票 09:已画到地图上的标记数与状态分布。
-                    放在**这一行内**而不是新起一行:底部信息带每多一行,就会顶到上面那块
-                    「无地图位置」浮块(实测多一行即相撞 10px)。图例本身已经带每状态的计数,
-                    这里只是把「地图上到底画了几个」再点一句。
-                    票 46:引导态没有计数浮标(规格 18),`v-if="entered"` 门控。
-                -->
-                <span v-if="entered" class="map-holo__markers" data-map-marker-count :title="`已上图状态分布:${markerStatusText}`">
-                    点位标记 {{ markerState.onMap }}
-                </span>
-            </div>
+            <!--
+                票 25:中央「一张图」标题与图层占位已并入右下角的地图信息集合面板。
+                此处**只保留状态筛选图例**——它是决策 02 冻结的全局组合筛选入口,必须常驻可点。
+            -->
         </div>
 
         <!--
-            票 07:不上图对象的可见落点(「无地图位置」+ 具体原因)。
-            判定全在 `domain/coordinate.js`,本组件只把结果画出来。
+            票 25:原先挂在地图四周的标题、图层占位、底图提示、加载状态、降级说明、标记计数、
+            坐标准入浮块与引擎读数,已并入**外壳的状态面板**(`CockpitShell.vue` 的
+            `cockpit-shell__status-details`)。它们不再出现在地图上,中央只保留状态筛选图例
+            与 3D 场景;维护者反馈过另起面板会和图例、外壳面板互相占用容器。
         -->
-        <MapCoordinateBlocked :overview="coordinateOverview" />
     </div>
 </template>
 
@@ -106,6 +70,9 @@ import {
     publishSummaryHandle,
     resizeMap,
     syncPointMarkersForPlan,
+    // 票 24:设施点位与管线图层。与摘要标记分属不同数据源与不同业务对象,
+    // 各自同步,互不写对方的状态。
+    syncPipeLayerForPlan,
     unmountMap,
 } from '../services/mapService.js'
 import { MAP_SUMMARY_PHASE } from '../domain/mapSummary.js'
@@ -120,8 +87,6 @@ import { useDeviceLayerStore } from '../stores/deviceLayerStore.js'
 import { useFilterStore } from '../stores/filterStore.js'
 import { useMapStore } from '../stores/mapStore.js'
 import { useDetailStore } from '../stores/detailStore.js'
-import CockpitIcon from './CockpitIcon.vue'
-import MapCoordinateBlocked from './MapCoordinateBlocked.vue'
 import StatusTag from './StatusTag.vue'
 
 const props = defineProps({
@@ -135,19 +100,12 @@ const props = defineProps({
     /**
      * 票 46:模式图层计划的输出(`domain/modeLayerPlan.js` 的 `planModeLayers()` 结果)。
      * 本组件**只消费它**:不自己判模式、不自己过滤 objects、不自己算图例。
-     * 传入 `null`(理论不发生)时按空计划处理,保证组件不崩。
+     * 传入 
+ull`(理论不发生)时按空计划处理,保证组件不崩。
      */
     plan: { type: Object, default: null },
 })
 
-// 图层开关的占位(真实开关见票 21/22);图标名对应 domain/icons.js。
-const LAYERS = [
-    { icon: 'layers', label: '管线图层' },
-    { icon: 'points', label: '监测点' },
-    { icon: 'event', label: '事件定位' },
-    { icon: 'resource', label: '应急资源' },
-]
-
 // 走 Vite 的 BASE_URL,保证 qiankun / 相对 base 部署下也能取到 public 资源。
 const base = import.meta.env.BASE_URL || '/'
 const photoUrl = `${base}cockpit/aerial.webp`
@@ -305,6 +263,10 @@ function applyMarkers() {
     })
     markerState.value = getPointMarkerState()
 
+    // 票 24:设施点位与管线跟着**同一份模式计划**走 —— 图层显隐因此与监测点标记
+    // 必然一致(计划里 hidden 就是空数组 → 自动撤下),不会出现两套判定打架。
+    syncPipeLayerForPlan(modePlan.value)
+
     // 票 46:降级说明写进宿主 dataset(走查断言「数据未就绪 ≠ 空数据」的断言面)。
     const host = hostRef.value
     if (host && host.dataset) {
@@ -339,7 +301,7 @@ onMounted(() => {
     // 票 09/46:摘要翻页、计划变化(模式/引导态/异常设备都在计划里体现)→ 重画一次
     // (服务层只消费计划,重画是唯一路径;一个 watch 一份退订,不叠两份)。
     stopMarkerWatch = watch(
-        () => [mapStore.summary.objects, modePlan.value.markers, modePlan.value.byStatus],
+        () => [mapStore.summary.objects, modePlan.value.markers, modePlan.value.byStatus, modePlan.value.pipelines, modePlan.value.facilities],
         () => applyMarkers(),
         { deep: false },
     )
@@ -491,51 +453,8 @@ onBeforeUnmount(() => {
     pointer-events: none;
 }
 
-.map-holo__headline {
-    text-align: center;
-}
-
-.map-holo__top b {
-    display: block;
-    color: #eaf9ff;
-    font-size: calc(17 * var(--u, 1px));
-    font-weight: 700;
-    letter-spacing: 0.14em;
-    text-shadow: 0 0 16px rgba(79, 216, 255, 0.85), 0 2px 8px rgba(0, 0, 0, 0.9);
-}
-
-.map-holo__layers {
-    display: flex;
-    gap: calc(5 * var(--u, 1px));
-    justify-content: center;
-    margin-top: calc(6 * var(--u, 1px));
-    flex-wrap: wrap;
-}
 
-.map-holo__layers span {
-    display: inline-flex;
-    align-items: center;
-    gap: calc(4 * var(--u, 1px));
-    padding: calc(2 * var(--u, 1px)) calc(8 * var(--u, 1px));
-    border: 1px solid rgba(58, 170, 226, 0.42);
-    background: rgba(4, 20, 38, 0.66);
-    color: #bad4e0;
-    font-size: calc(10 * var(--u, 1px));
-    letter-spacing: 0.04em;
-}
 
-.map-holo__bottom {
-    position: absolute;
-    z-index: 2;
-    bottom: calc(8 * var(--u, 1px));
-    left: 50%;
-    transform: translateX(-50%);
-    display: grid;
-    gap: calc(4 * var(--u, 1px));
-    justify-items: center;
-    text-align: center;
-    pointer-events: none;
-}
 
 /* 图例容器:标签外观由 StatusTag 提供(颜色取自 token,语义与颜色不再硬编码在这里) */
 .map-holo__legend {
@@ -585,86 +504,17 @@ onBeforeUnmount(() => {
 
 /* 票 35 续:生产形态把底部摘要行也收进**右下角状态日志**那一摞,
    中央视野完全空出来(与引擎状态同一条右边线、在其上方)。 */
-.map-holo__bottom.is-quiet {
-    left: auto;
-    right: calc(var(--column-w, 300px) + 10 * var(--u, 1px));
-    bottom: calc(22 * var(--u, 1px));
-    transform: none;
-    justify-items: end;
-    text-align: right;
-}
 
-.map-canvas__title {
-    margin: 0;
-    color: #eaf9ff;
-    font-size: calc(13 * var(--u, 1px));
-    font-weight: 700;
-    letter-spacing: 0.16em;
-    text-shadow: 0 0 14px rgba(79, 216, 255, 0.7), 0 2px 8px rgba(0, 0, 0, 0.9);
-}
 
-.map-holo__hint {
-    margin: 0;
-    color: var(--muted, #6d92ae);
-    font-size: calc(10.5 * var(--u, 1px));
-    text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
-}
-
-.map-holo__status {
-    display: flex;
-    align-items: center;
-    gap: 7px;
-    color: var(--muted, #6d92ae);
-    font-size: calc(10.5 * var(--u, 1px));
-    text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
-}
 
 /* 票 32:生产形态的状态行 —— 再小一点、再淡一点,让位于 3D 场景(状态灯的颜色语义保留)。 */
-.map-holo__status.is-quiet {
-    color: rgba(186, 212, 224, 0.5);
-    font-size: calc(9.5 * var(--u, 1px));
-    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
-}
-
-.map-holo__status.is-quiet .map-holo__markers {
-    border-left-color: rgba(58, 170, 226, 0.2);
-}
 
 /* 票 09:标记数与状态灯同一行;左侧加一条分隔线,免得跟摘要文案连成一串。 */
-.map-holo__markers {
-    padding-left: 7px;
-    border-left: 1px solid rgba(58, 170, 226, 0.32);
-    white-space: nowrap;
-}
 
 /* 状态灯:默认(加载中/待取数)呼吸的橙点;加载完成转绿、失败/快照失效转红。 */
-.map-holo__status i {
-    width: calc(7 * var(--u, 1px));
-    height: calc(7 * var(--u, 1px));
-    border-radius: 50%;
-    background: var(--lv2, #ffa63d);
-    box-shadow: 0 0 8px var(--lv2, #ffa63d);
-    animation: map-breathe 1.8s ease-in-out infinite;
-}
 
-.map-holo__status i.is-done {
-    background: var(--lv4, #35d6a4);
-    box-shadow: 0 0 8px var(--lv4, #35d6a4);
-}
-
-.map-holo__status i.is-bad {
-    background: var(--lv1, #ff4d5e);
-    box-shadow: 0 0 8px var(--lv1, #ff4d5e);
-}
 
 /* 票 46:模式图层计划的降级说明(「设施数据未就绪」等)。与状态灯同一行、小字弱化。 */
-.map-holo__degraded {
-    color: var(--lv2, #ffa63d);
-    font-size: var(--fs-caption, 10.5px);
-    white-space: nowrap;
-    padding-left: 7px;
-    border-left: 1px solid rgba(255, 166, 61, 0.4);
-}
 
 @keyframes map-breathe {
     0%, 100% { opacity: 1; transform: scale(1); }

+ 71 - 5
src/modules/cockpit/components/MapCoordinateBlocked.vue

@@ -18,7 +18,7 @@
   这样票 07 走查读 `data-map-coordinate-backend/-loaded` 文本的断言不受形态影响。
 -->
 <template>
-    <div class="map-blocked" :class="{ 'is-compact': !expanded }" data-map-blocked-panel>
+    <div class="map-blocked" :class="{ 'is-compact': !expanded, 'map-blocked--embedded': embedded }" data-map-blocked-panel>
         <div class="map-blocked__head">
             <CockpitIcon name="points" size="calc(11 * var(--u, 1px))" />
             <b>无地图位置</b>
@@ -60,6 +60,11 @@ import CockpitIcon from './CockpitIcon.vue'
 const props = defineProps({
     /** `domain/coordinate.js` 的 `coordinateOverview()` 结果 */
     overview: { type: Object, required: true },
+    /**
+     * 票 25:并入地图信息集合面板时为 true。
+     * 原先它是**绝对定位**在地图右下角的独立浮块(会压画面),现在改为跟随面板的常规流。
+     */
+    embedded: { type: Boolean, default: false },
 })
 
 const expanded = ref(false)
@@ -178,9 +183,14 @@ const blockedCount = computed(() => (props.overview && props.overview.loaded ? p
     gap: calc(2 * var(--u, 1px));
 }
 
+/*
+ * 名称与编码**都不截断**(维护者反馈过编码被从右侧切掉)。
+ * 原来名称用 `nowrap + ellipsis`、编码放任被网格挤掉,长编码直接看不见。
+ * 现在两者都允许换行,网格列用 `minmax(0, …)` 防止 `auto` 列把容器顶破。
+ */
 .map-blocked__list li {
     display: grid;
-    grid-template-columns: 1fr auto;
+    grid-template-columns: minmax(0, 1fr) minmax(0, auto);
     gap: 0 calc(4 * var(--u, 1px));
     padding-top: calc(2 * var(--u, 1px));
     border-top: 1px dashed rgba(58, 170, 226, 0.22);
@@ -188,14 +198,16 @@ const blockedCount = computed(() => (props.overview && props.overview.loaded ? p
 
 .map-blocked__name {
     color: var(--text-2, #a8cbe2);
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
+    white-space: normal;
+    overflow-wrap: anywhere;
 }
 
 .map-blocked__code {
     color: var(--muted, #6d92ae);
     font-family: var(--num, monospace);
+    white-space: normal;
+    overflow-wrap: anywhere;
+    text-align: right;
 }
 
 /* 具体原因独占一行,保证读得到(颜色不是唯一线索,文字始终在) */
@@ -207,4 +219,58 @@ const blockedCount = computed(() => (props.overview && props.overview.loaded ? p
 .map-blocked__empty {
     color: var(--muted, #6d92ae);
 }
+
+/*
+ * 票 25:并入地图信息集合面板后不再是绝对定位浮块 —— 跟随面板常规流,
+ * 不再压地图,也不再自己算「让开右栏/底带」的位置。
+ *
+ * 选择器写成 `.map-blocked.map-blocked--embedded`(而不是单类)是有原因的:
+ * `.map-blocked.is-compact` 自带 `max-width: min(280px, 34%)`,优先级更高;
+ * 单类写法压不过它,面板里那栏就会被压到 34% 宽、把「无地图位置」挤成竖排。
+ */
+.map-blocked.map-blocked--embedded,
+.map-blocked.map-blocked--embedded.is-compact {
+    position: static;
+    right: auto;
+    bottom: auto;
+    width: auto;
+    max-width: 100%;
+    /* 「容器异常占用」的根因:原样式带 `max-height: 30%`,是给「压在地图上的浮块」设计的。
+       嵌进状态面板后那套高度毫无意义,却会让块看起来比内容高一大截。 */
+    height: auto;
+    max-height: none;
+    /* 绝不跟着父容器拉高——只占内容该占的高度。 */
+    align-self: start;
+    margin: 0;
+    text-align: left;
+    border: 0;
+    border-top: 1px solid rgba(58, 170, 226, 0.18);
+    border-radius: 0;
+    padding: calc(3 * var(--u, 1px)) 0 0;
+    background: none;
+    overflow: visible;
+}
+
+/* 列表再长也自己滚动,不把外层容器撑高。 */
+.map-blocked.map-blocked--embedded .map-blocked__list {
+    max-height: 40vh;
+    overflow: auto;
+}
+
+/*
+ * 嵌入态把「名称 / 编码」改成**上下两行**,而不是并排两列。
+ *
+ * 原因:并排时编码列是 `auto` 轨道,会撑到 max-content —— 状态面板只有 560px 宽,
+ * 于是长编码把整行顶出容器,被裁在右边(维护者截图里正是「0500…」「PS00…」这样被切掉)。
+ * 单列之后编码独占一行并可换行,任何长度都完整可见。
+ */
+.map-blocked.map-blocked--embedded .map-blocked__list li {
+    grid-template-columns: minmax(0, 1fr);
+}
+
+.map-blocked.map-blocked--embedded .map-blocked__code {
+    text-align: left;
+    overflow-wrap: anywhere;
+    word-break: break-all;
+}
 </style>

+ 191 - 0
src/modules/cockpit/components/MapInfoPanel.vue

@@ -0,0 +1,191 @@
+<!--
+  地图信息(票 25)。
+
+  **位置说明**:本组件**不自己定位**——它是一段纯内容块,挂在外壳已有的
+  状态面板 `cockpit-shell__status-summary` 展开区里(与「数据分区」「状态推送」「更新」同框)。
+  维护者 2026-10-10 反馈过:另起一个右上角面板会和顶部状态图例、外壳状态面板
+  **互相占用容器**(图一 vs 图二)。现在只有**一个**容器,展开/收起也复用外壳那一个开关。
+
+  **为什么保留这些行**:原先它们散挂在地图四周,互相压叠、遮住中央视野;
+  集中到一处后,中央只剩状态筛选图例 + 3D 场景。
+
+  **署名纪律**:Cesium 自带署名容器已被 `services/cesiumEngine.js` 换成离屏容器,
+  因此数据来源署名(底图/叠加影像/3D Tiles 的 credit)必须由这里承担。
+
+  **不截断**:所有文本一律换行显示。被删掉的 `ellipsis` 正是「信息被从中间切除」的元凶。
+-->
+<template>
+    <div class="map-info" data-map-info-panel>
+        <p v-if="modeLabel" class="map-info__line">当前模式:{{ modeLabel }}</p>
+
+        <p
+            v-for="note in degradedNotes"
+            :key="note.key"
+            class="map-info__note"
+            :data-map-degraded-note="note.key"
+            role="status"
+        >{{ note.text }}</p>
+
+        <div class="map-info__layers">
+            <span v-for="layer in LAYERS" :key="layer.label" class="map-info__layer-chip">
+                <CockpitIcon :name="layer.icon" size="calc(14 * var(--u, 1px))" />
+                {{ layer.label }}
+            </span>
+        </div>
+
+        <p class="map-info__line" data-map-marker-count :title="`已上图状态分布:${markerStatusText}`">
+            点位标记 {{ markerOnMap }}
+        </p>
+
+        <p v-if="uiDebug" class="map-info__line map-info__line--muted">
+            实例 {{ instanceId }} · {{ basemapLabel }}
+        </p>
+
+        <!-- 坐标准入明细(票 07):原先是压在地图上的独立浮块,现随本内容块一起进入状态面板。 -->
+        <MapCoordinateBlocked :overview="coordinateOverview" :embedded="true" />
+
+        <!-- 引擎读数:mapService 自建的节点被搬到这里(票 25)。 -->
+        <div class="map-info__engine" ref="engineSlotRef" data-map-info-engine></div>
+
+        <ul v-if="credits.length" class="map-info__credits" data-map-info-credits>
+            <li v-for="item in credits" :key="item">{{ item }}</li>
+        </ul>
+    </div>
+</template>
+
+<script setup>
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+import CockpitIcon from './CockpitIcon.vue'
+import MapCoordinateBlocked from './MapCoordinateBlocked.vue'
+
+defineProps({
+    modeLabel: { type: String, default: '' },
+    uiDebug: { type: Boolean, default: false },
+    instanceId: { type: String, default: '' },
+    degradedNotes: { type: Array, default: () => [] },
+    markerOnMap: { type: Number, default: 0 },
+    markerStatusText: { type: String, default: '' },
+    coordinateOverview: { type: Object, default: () => ({}) },
+    basemapLabel: { type: String, default: '' },
+    credits: { type: Array, default: () => [] },
+})
+
+/** 图层占位(真实开关见票 21/22);图标名对应 domain/icons.js。 */
+const LAYERS = [
+    { icon: 'layers', label: '管线图层' },
+    { icon: 'points', label: '监测点' },
+    { icon: 'event', label: '事件定位' },
+    { icon: 'resource', label: '应急资源' },
+]
+
+/**
+ * 把 mapService 自建的 `.map-engine-status` 节点搬进本插槽。
+ *
+ * 为什么可以搬:mapService 只在**首次**创建时 `appendChild`(之后仅改 textContent/style),
+ * 因此换父节点后它仍由同一处更新,不会被重复插入或丢失。
+ * 外壳的状态面板是 `v-show`(不是 v-if),所以插槽节点常驻,挂载后搬一次即可。
+ */
+const engineSlotRef = ref(null)
+let observer = null
+
+function relocateEngineNode() {
+    const slot = engineSlotRef.value
+    if (!slot) return
+    const node = document.querySelector('.map-engine-status')
+    if (node && node.parentNode !== slot) slot.appendChild(node)
+}
+
+onMounted(() => {
+    relocateEngineNode()
+    // 引擎节点是 mapService 异步建的,插槽先空着;用观察器补搬一次即可。
+    const host = document.querySelector('.map-canvas')
+    if (host) {
+        observer = new MutationObserver(relocateEngineNode)
+        observer.observe(host, { childList: true })
+    }
+})
+onBeforeUnmount(() => {
+    if (observer) observer.disconnect()
+    observer = null
+})
+</script>
+
+<style scoped>
+/*
+ * 内容块:不定位、不设宽度上限——宽度由外壳的状态面板决定。
+ * 「容器异常占用」的另一半也在这里根治:本块**永远只占内容高度**,
+ * 既不绝对定位、也不写死高度,更不会给父容器留下预留空白。
+ *
+ * ⚠️ **不要在这里写 `justify-items: start`**:本块是 grid,`justify-items` 会作用到
+ * **所有子项**,让它们按 `fit-content` 收缩而不是撑满容器 —— 展开后的「无地图位置」
+ * 清单因此比容器窄一大截(维护者 2026-10-10 走查截图)。子项要撑满就保持默认的
+ * `stretch`;确实需要按内容宽度的,用子项自己的 `justify-self`。
+ */
+.map-info {
+    display: grid;
+    gap: calc(3 * var(--u, 1px));
+    color: var(--muted, #6d92ae);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.03em;
+    text-align: left;
+}
+
+/* 子项一律撑满容器宽度(显式写出来,避免日后有人再给父级加对齐规则把它收窄)。 */
+.map-info > * {
+    justify-self: stretch;
+    min-width: 0;
+    max-width: 100%;
+}
+
+.map-info__layers {
+    display: flex;
+    flex-wrap: wrap;
+    gap: calc(8 * var(--u, 1px));
+    /* 这一行是「并排的小标签」,按内容宽度排即可,不需要撑满。 */
+    justify-self: start;
+}
+
+/*
+ * 每个标签是 inline-flex:`.cockpit-icon` 是 `display: block`,
+ * 若标签仍是行内元素,图标会把文字挤到下一行(维护者走查发现的「自动换行很奇怪」)。
+ * 显式让图标与文字在同一行内居中,并禁止标签内部折行。
+ */
+.map-info__layer-chip {
+    display: inline-flex;
+    align-items: center;
+    gap: calc(4 * var(--u, 1px));
+    white-space: nowrap;
+}
+
+.map-info__note,
+.map-info__line {
+    margin: 0;
+    /* 换行而不是省略:宁可多占一行,也不能少说一句。 */
+    white-space: normal;
+    overflow-wrap: anywhere;
+}
+
+.map-info__line {
+    color: var(--text-2, #a8cbe2);
+}
+
+.map-info__line--muted {
+    color: var(--muted, #6d92ae);
+}
+
+.map-info__engine {
+    max-width: 100%;
+    color: var(--muted, #6d92ae);
+    /* 引擎读数带内联样式,这里兜底确保不被截断。 */
+    white-space: normal !important;
+    overflow: visible !important;
+    text-overflow: clip !important;
+}
+
+.map-info__credits {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+    color: var(--muted, #6d92ae);
+}
+</style>

+ 19 - 0
src/modules/cockpit/domain/filterOptions.js

@@ -23,6 +23,25 @@ export const INDUSTRIES = [
     { key: 'manhole', label: '窨井' },
 ]
 
+/**
+ * 行业图标(`CockpitIcon` 的语义名),取自蓝湖 2026-10-12 版正式切图。
+ *
+ * 单独成表而不写进 `INDUSTRIES` 的条目里:`INDUSTRIES` 是筛选契约本身,
+ * 断言与 `industryQuery()` 都按它的既有结构走,**图标这类纯视觉信息不进契约**。
+ * `all`(全部)不对应任何图标 → 返回空串,渲染层不画。
+ */
+export const INDUSTRY_ICONS = {
+    gas: 'gas',
+    water: 'water',
+    drainage: 'drainage',
+    manhole: 'manhole',
+}
+
+/** 行业图标语义名;无对应图标时返回空串。 */
+export function industryIcon(key) {
+    return INDUSTRY_ICONS[key] || ''
+}
+
 export const DEFAULT_SCOPE = 'all'
 export const DEFAULT_INDUSTRY = 'all'
 /**

+ 24 - 11
src/modules/cockpit/domain/icons.js

@@ -7,12 +7,25 @@
 import { resolveAsset } from './runtimeAssets.js'
 
 const ICON_SPECS = {
-    brand: { assetKey: 'icon.brand', viewBox: '0 0 32 32' },
-    layers: { assetKey: 'icon.layers', viewBox: '0 0 16 16' },
-    points: { assetKey: 'icon.points', viewBox: '0 0 16 16' },
-    event: { assetKey: 'icon.event', viewBox: '0 0 16 16' },
-    resource: { assetKey: 'icon.resource', viewBox: '0 0 16 16' },
-    blank: { assetKey: 'icon.blank', viewBox: '0 0 48 32' },
+    brand: 'icon.brand',
+    layers: 'icon.layers',
+    points: 'icon.points',
+    event: 'icon.event',
+    resource: 'icon.resource',
+    blank: 'icon.blank',
+    // 面板标题 / 行业筛选等落点(图标全面应用批次)
+    infrastructure: 'icon.infrastructure',
+    facility: 'icon.facility',
+    safety: 'icon.safety',
+    history: 'icon.history',
+    efficiency: 'icon.efficiency',
+    pulse: 'icon.pulse',
+    close: 'icon.close',
+    emergency: 'icon.emergency',
+    gas: 'icon.industry.gas',
+    water: 'icon.industry.water',
+    drainage: 'icon.industry.drainage',
+    manhole: 'icon.manhole',
 }
 
 export const ICON_NAMES = Object.keys(ICON_SPECS)
@@ -22,9 +35,9 @@ export function isKnownIcon(name) {
 }
 
 export function iconFor(name, options = {}) {
-    const spec = ICON_SPECS[name]
-    if (!spec) return null
-    const asset = resolveAsset(spec.assetKey, options)
+    const assetKey = ICON_SPECS[name]
+    if (!assetKey) return null
+    const asset = resolveAsset(assetKey, options)
     if (!asset.url) return null
-    return { ...spec, ...asset }
-}
+    return { name, ...asset }
+}

+ 1 - 0
src/modules/cockpit/domain/mapLayers.js

@@ -90,6 +90,7 @@ export function planTilesets(mapConfig = {}) {
             eager: item.eager !== false,
             role: item.role || '',
             credit: item.credit || '',
+            progressiveRefinement: item.progressiveRefinement === true,
             ...(item.lighting === undefined ? {} : { lighting: item.lighting === true }),
         }))
 }

+ 12 - 4
src/modules/cockpit/domain/mapSummary.js

@@ -12,6 +12,7 @@
  */
 import { fieldReader, toLocation } from './fields.js'
 import { splitByCoordinate, toCoordinateSummary } from './coordinate.js'
+import { POINT_STATUS_FILTER_KEYS } from './filterOptions.js'
 
 /**
  * 八桶全局计数(票 14;决策 02/规格 07 的后端契约:`map-snapshot` 在**未筛口径**下提供
@@ -24,14 +25,17 @@ import { splitByCoordinate, toCoordinateSummary } from './coordinate.js'
  */
 export function toStatusDistribution(raw) {
     const list = Array.isArray(raw && raw.pointStatusDistribution) ? raw.pointStatusDistribution : null
-    if (!list) return { available: false, counts: {} }
+    const unavailable = () => ({ available: false, counts: {} })
+    if (!list) return unavailable()
     const counts = {}
     for (const item of list) {
         const status = item && typeof item.status === 'string' ? item.status.trim() : ''
-        if (!status) continue
-        const count = Number(item && item.count)
-        counts[status] = Number.isFinite(count) ? count : 0
+        const count = item && item.count
+        if (!POINT_STATUS_FILTER_KEYS.includes(status) || Object.hasOwn(counts, status) ||
+            typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0) return unavailable()
+        counts[status] = count
     }
+    if (!POINT_STATUS_FILTER_KEYS.every((key) => Object.hasOwn(counts, key))) return unavailable()
     return { available: true, counts }
 }
 
@@ -395,6 +399,10 @@ export function createMapSummaryLoader(options = {}) {
         if (state.phase === MAP_SUMMARY_PHASE.COMPLETE || state.phase === MAP_SUMMARY_PHASE.EMPTY) return readState()
 
         const wanted = state.nextPage
+        if (wanted > 1 && !state.snapshot) {
+            state = { ...state, phase: MAP_SUMMARY_PHASE.FAILED, reason: 'snapshot-token-missing' }
+            return readState()
+        }
         // 同一批加载里的后续页必须带第 1 页锁定的快照;第一页不带(后端据此开新快照)。
         const query = { page: wanted, pageSize }
         if (state.snapshot) query.snapshotAt = state.snapshot

+ 8 - 2
src/modules/cockpit/domain/monitoringPanels.js

@@ -697,6 +697,11 @@ function abnormalTone(status) {
 export function monitoringPointLedgerPanel(data, section, badges = {}) {
     const current = data || {}
     const pagination = current.pagination || {}
+    // 分母认不出就**整句不出**(作者本意),且绝不用 `undefined` 冒充读数:
+    // `data` 为 null(真实空态/失败态,见 `sectionFromResult()` 的 `isEmpty` 分支)时
+    // `pagination` 是 `{}`,`total` 是 `undefined` 而不是 `null` —— 只判 `=== null` 会把
+    // 它漏进模板串。`numberish()` 是仓库里「数字/数字串→数字,认不出回 null」的唯一口径。
+    const total = numberish(pagination.total)
 
     const cards = (current.points || []).map((point) => {
         const visual = visualForStatus(point.status)
@@ -747,10 +752,11 @@ export function monitoringPointLedgerPanel(data, section, badges = {}) {
         locateNote: '点击卡片进入与地图一致的两级详情(点位摘要 → 设备卡片);本面板不移动相机',
         // 分页口径:本端点分页窗口横跨「监测点 + 未关联设备」,所以 total 含设备,
         // 卡片数会小于 total —— 这个差值说明白,免得读成"还有一些点没列出来"。
+        // 分母缺失(`total === null`)时整句不出:宁可不说,也不报一个假读数。
         pageNote:
-            pagination.total === null
+            total === null
                 ? ''
-                : `已列出 ${cards.length} 个点位 · 本端点序列共 ${pagination.total} 条(含未关联设备)${pagination.hasMore ? ' · 还有下一页' : ''}`,
+                : `已列出 ${cards.length} 个点位 · 本端点序列共 ${total} 条(含未关联设备)${pagination.hasMore ? ' · 还有下一页' : ''}`,
         hasMore: pagination.hasMore === true,
         page: pagination.page,
         missing: panelMissingNote(current),

+ 34 - 0
src/modules/cockpit/domain/panelCatalog.js

@@ -59,6 +59,35 @@ const COLUMNS = {
     },
 }
 
+/**
+ * 二级面板图标(`CockpitIcon` 的语义名,按 panelId 映射)。
+ *
+ * 批次 1「图标全面应用」新增:图标取自蓝湖 2026-10-12 版正式切图,按面板语义对应
+ * (见 manifest `appliesTo.meaning`)。**纯视觉**:不参与面板标识、排序与断言,
+ * 走查认面板仍只认 `data-panel-id`。
+ *
+ * 未列出的 panelId 返回空串 → 渲染层不画图标,不抛错(与 `iconFor` 的未知名行为一致)。
+ */
+export const PANEL_ICONS = {
+    // 维护者澄清:那枚紫色网格球实为「井盖」,不可再用作态势总览。
+    situationSummary: 'infrastructure',
+    infrastructureSummary: 'infrastructure',
+    monitoringSummary: 'pulse',
+    alertSettings: 'event',
+    archive: 'facility',
+    operation: 'pulse',
+    management: 'facility',
+    emergency: 'emergency',
+    monitoringOverview: 'pulse',
+    monitoringDistribution: 'facility',
+    operationOverview: 'pulse',
+    monitoringPointLedger: 'points',
+    currentEvents: 'event',
+    historyAnalysis: 'history',
+    responseEfficiency: 'efficiency',
+    eventAnalysis: 'event',
+}
+
 export function isKnownMode(mode) {
     return MODES.includes(mode)
 }
@@ -75,6 +104,11 @@ export function labelForPanel(panelId) {
     return PANEL_LABELS[panelId] || panelId
 }
 
+/** 面板图标语义名;无对应图标时返回空串(渲染层据此不画图标)。 */
+export function panelIcon(panelId) {
+    return PANEL_ICONS[panelId] || ''
+}
+
 /** 返回模式的左右两栏面板;未知模式直接报错,避免静默渲染空壳。 */
 export function columnsForMode(mode) {
     const columns = COLUMNS[mode]

+ 4 - 0
src/modules/cockpit/domain/runtimeAssets.js

@@ -119,6 +119,10 @@ export function resolveAsset(key, options = {}) {
         status: entry.status,
         core: entry.core,
         fallbackKey: entry.fallbackKey,
+        // 正式切图是彩色位图(image);单色遮罩资源走 mask。渲染组件按此二选一,接缝不变。
+        renderMode: entry.renderMode === 'image' ? 'image' : 'mask',
+        dimensions: entry.dimensions,
+        pixelRatio: entry.pixelRatio === 2 ? 2 : 1,
         diagnostic: ASSET_DIAGNOSTIC.OK,
     }
 }

+ 23 - 6
src/modules/cockpit/services/cesiumEngine.js

@@ -100,12 +100,28 @@ function createBaseLayer(plan) {
 
 /**
  * 在宿主要素上创建 Viewer。
+ *
+ * **署名容器(票 25)**:`creditContainer` **不是开关,是「放哪个容器」**——
+ * Cesium 的 Viewer 构造写死 `defined(options.creditContainer) ? options.creditContainer : bottomContainer`,
+ * 随后 CesiumWidget **两条路径都会**建 CreditDisplay
+ * (`creditContainer.appendChild(innerCreditContainer)`)。
+ * 所以传 `false`/`''` 既关不掉、还会让 `getElement()` 拿到 null 而崩。
+ * 真正能生效的做法是**自己给一个不进 DOM 的容器**:Cesium 照旧把署名写进去,
+ * 但它不在文档流里,屏幕上不出现,destroy 时 Cesium 也会自行摘掉内部节点。
+ *
+ * 合法性前提(本部署已核实,勿轻易推翻):地形走 `CesiumTerrainProvider.fromUrl(本地 URL)`、
+ * 影像走内置 TMS 与 `UrlTemplateImageryProvider`、3D Tiles 走 `Cesium3DTileset.fromUrl(本地 URL)`,
+ * **全链路无 Cesium ion 服务与 ion 资产**。ion 署名义务只在用到 ion 资产时成立,
+ * 故此处隐藏署名不构成违约;自有数据源署名由地图信息面板在业务界面另行展示。
+ *
  * @param {Element} host 宿主要素(`.map-canvas`)
  * @param {object} [basemapPlan] `domain/basemap.js` 的底图计划(票 29);缺省 = 不配底图
  * @returns {Viewer}
  */
 export function createViewer(host, basemapPlan) {
-    const viewer = new Viewer(host, { ...VIEWER_OPTIONS, baseLayer: createBaseLayer(basemapPlan) })
+    // 票 25:离屏署名容器 —— 故意不 appendChild,署名因此不上屏。
+    const creditContainer = document.createElement('div')
+    const viewer = new Viewer(host, { ...VIEWER_OPTIONS, creditContainer, baseLayer: createBaseLayer(basemapPlan) })
 
     const controller = viewer.scene.screenSpaceCameraController
     controller.enableRotate = false
@@ -381,22 +397,23 @@ async function readTilesetRootBounds(plan, signal) {
 }
 
 function tilesetOptions(plan) {
+    const continuous = plan.progressiveRefinement === true
     const options = {
-        skipLevelOfDetail: true,
+        skipLevelOfDetail: !continuous,
         baseScreenSpaceError: 4096,
         skipScreenSpaceErrorFactor: 32,
         skipLevels: 2,
         immediatelyLoadDesiredLevelOfDetail: false,
         loadSiblings: false,
         cullRequestsWhileMoving: true,
-        cullRequestsWhileMovingMultiplier: 90,
+        cullRequestsWhileMovingMultiplier: continuous ? 30 : 90,
         // Cesium 的动态 SSE 内部读相机高度;只保留瓦片距离投影的普通 SSE。
         dynamicScreenSpaceError: false,
-        preloadWhenHidden: false,
-        preloadFlightDestinations: false,
+        preloadWhenHidden: continuous,
+        preloadFlightDestinations: continuous,
         foveatedScreenSpaceError: true,
         foveatedConeSize: 0.1,
-        foveatedTimeDelay: 0.6,
+        foveatedTimeDelay: continuous ? 0.15 : 0.6,
         foveatedMinimumScreenSpaceErrorRelaxation: 0,
         // 普通 SSE 根据瓦片误差与相机到瓦片的距离决定细化,保持配置的质量取舍。
         maximumScreenSpaceError: Number.isFinite(plan.maximumScreenSpaceError) ? plan.maximumScreenSpaceError : 96,

+ 3 - 0
src/modules/cockpit/services/cockpitConfig.js

@@ -301,6 +301,9 @@ export function normalizeConfig(raw) {
                 eager: readBoolean(tileset.eager, true, issues, `map.tilesets[${index}].eager`),
                 role: readString(tileset.role, '', issues, `map.tilesets[${index}].role`),
                 credit: readString(tileset.credit, '', issues, `map.tilesets[${index}].credit`),
+                progressiveRefinement: readBoolean(tileset.progressiveRefinement, false, issues, `map.tilesets[${index}].progressiveRefinement`),
+                preloadDistance: readNonNegativeNumber(tileset.preloadDistance, issues, `map.tilesets[${index}].preloadDistance`),
+                maxConcurrentStreams: readNonNegativeNumber(tileset.maxConcurrentStreams, issues, `map.tilesets[${index}].maxConcurrentStreams`),
                 ...(tileset.lighting === undefined ? {} : { lighting: readBoolean(tileset.lighting, false, issues, `map.tilesets[${index}].lighting`) }),
             }
         })

+ 80 - 43
src/modules/cockpit/services/mapService.js

@@ -92,53 +92,31 @@ const ENGINE_TEXT = {
 }
 
 // 状态浮标由服务层自绘并挂在宿主内:外壳与 MapCanvas 组件按票面要求不改。
-// 票 32:**调试形态**(dev / 显式开 debug)用顶部药丸,**生产形态**用底部半透明小字 ——
-// 正式环境不该有一大块状态文本压在中央 3D 场景上(维护者 2026-09-18 要求)。
-const STATUS_STYLE = [
-    'position:absolute',
-    'z-index:3',
-    // 图例已挪到一张图最上方,药丸改到「图例 + 标题」之下,避免压住颜色语义。
-    'top:96px',
-    'left:50%',
-    'transform:translateX(-50%)',
-    'max-width:min(78%, 620px)',
-    'padding:3px 10px',
-    'border:1px solid rgba(58,170,226,.42)',
-    'background:rgba(4,20,38,.72)',
-    'color:#bad4e0',
-    'font-size:11px',
-    'line-height:1.4',
-    'letter-spacing:.04em',
-    'white-space:nowrap',
-    'overflow:hidden',
-    'text-overflow:ellipsis',
+//
+// 票 25 修订:这两个节点**不再自绘浮标**——它们被 `MapInfoPanel` 搬进右下角/右上角的
+// 状态日志集合里(`MapCanvas` 的 `relocateEngineNode()`),所以样式改为**布局中性**:
+// 不自己定位、不限宽、**不截断**。
+// 原先的 `white-space:nowrap + overflow:hidden + text-overflow:ellipsis`
+// 会把「Cesium 引擎已就绪 · 主场景区 · 底图 · 地形 · 叠加影像 · 3D Tiles …」
+// 从中间切断(维护者 2026-10-10 走查反馈:「左右显示不完整,信息被部分切除」),
+// 而被切的恰恰是排障最需要的那一整句。两种形态现在只差**配色**,不再差布局。
+const STATUS_STYLE_BASE = [
+    'position:static',
     'pointer-events:none',
-].join(';')
-
-/** 生产形态:收紧成**右下角状态日志**(右对齐、无边框无底色、半透明小字)。
- *  位置与「无地图位置」胶囊共用同一条右边线(让开右栏 `--column-w`),日志在最下、
- *  胶囊在其上一档,形成右下角的一小摞摘要;中央视野因此完全空出来。
- *  抬到 30px 而不是贴底 2px 的原因:底部中央还有一行地图摘要状态,贴底会与它同一水平线。 */
-const STATUS_STYLE_QUIET = [
-    'position:absolute',
-    'z-index:3',
-    'bottom:8px',
-    'right:calc(var(--column-w, 300px) + 10 * var(--u, 1px))',
-    'left:auto',
-    'transform:none',
-    'text-align:right',
-    'max-width:min(40%, 420px)',
-    'color:rgba(186,212,224,.5)',
-    'font-size:9.5px',
-    'line-height:1.2',
+    // 换行而不是省略:宁可多占一行,也不能少说一句。
+    'white-space:normal',
+    'overflow:visible',
+    'overflow-wrap:anywhere',
+    'line-height:1.45',
     'letter-spacing:.03em',
-    'white-space:nowrap',
-    'overflow:hidden',
-    'text-overflow:ellipsis',
-    'text-shadow:0 1px 3px rgba(0,0,0,.8)',
-    'pointer-events:none',
+    'font-size:var(--fs-caption, 10.5px)',
 ].join(';')
 
+/** 调试形态:带边框底色,便于开发期定位。 */
+const STATUS_STYLE = `${STATUS_STYLE_BASE};padding:3px 8px;border:1px solid rgba(58,170,226,.42);background:rgba(4,20,38,.72);color:#bad4e0`
+
+/** 生产形态:无边框无底色、半透明小字,让位于 3D 场景(颜色语义仍然保留)。 */
+const STATUS_STYLE_QUIET = `${STATUS_STYLE_BASE};padding:0;color:rgba(186,212,224,.62)`
 /** 当前是否走「调试形态」:配置显式给布尔值就听配置,否则跟随构建模式(生产构建自动收敛)。 */
 function isUiDebug() {    const mapConfig = getCockpitConfig().map || {}
     const configured = mapConfig.ui ? mapConfig.ui.debug : null
@@ -187,6 +165,9 @@ let terrainState = TERRAIN_STATE.NONE
 let terrainText = terrainStatusText(null)
 let terrainError = ''
 let overlayCount = 0
+// 票 25:数据来源署名。Cesium 的署名容器已被换成离屏容器(cesiumEngine.js),
+// 所以**自有数据源的署名改由地图信息面板展示**,这里只负责从同一批计划里取。
+let dataSourceCredits = []
 // 票 21:3D Tiles / 倾斜摄影(入口先留好,本机数据缺根 tileset.json 时如实报失败)
 let tilesetReady = 0
 let tilesetFailed = []
@@ -669,6 +650,13 @@ async function bootEngine(record) {
         const terrainPlan = planTerrain(mapConfig)
         const overlayPlans = planOverlays(mapConfig)
         const tilesetPlans = planTilesets(mapConfig)
+        // 票 25:署名与图层取自**同一批计划**(不另开一份真相);没有 credit 的图层不硬造一条。
+        dataSourceCredits = [
+            basemapPlan.credit,
+            ...overlayPlans.map((overlay) => overlay.credit),
+            ...tilesetPlans.map((plan) => plan.credit),
+        ].filter((credit) => typeof credit === 'string' && credit.trim())
+          .map((credit) => credit.trim())
         terrainState = terrainPlan.ready ? TERRAIN_STATE.READY : TERRAIN_STATE.NONE
         terrainText = terrainStatusText(terrainPlan)
         terrainError = ''
@@ -1245,6 +1233,26 @@ export function getPrimaryAreaPlan() {
     return { ...primaryAreaPlan, bounds: primaryAreaPlan.bounds ? primaryAreaPlan.bounds.slice() : null }
 }
 
+/**
+ * 底图措辞(票 25)。原为引擎浮标上的一句,现在由右下角信息面板展示。
+ * @returns {string} 人话措辞(如「底图:内置离线影像 Natural Earth II」)
+ */
+export function getBasemapLabel() {
+    return basemapText
+}
+
+/**
+ * 数据来源署名(票 25)。
+ *
+ * Cesium 自带的署名容器已被换成**离屏容器**(`services/cesiumEngine.js`),因此
+ * 底图/叠加影像/3D Tiles 的 credit 改由地图信息面板展示。这是「隐藏引擎署名」之后
+ * 仍然必须保留的那部分来源标注——本部署不用 ion,但自有影像的署名不能一起丢。
+ * @returns {string[]} 去重前的署名条目(空数组 = 本部署没有声明任何 credit)
+ */
+export function getDataSourceCredits() {
+    return [...dataSourceCredits]
+}
+
 // ---- 票 37:进区锁定与相机区域限位 ----
 
 /**
@@ -2352,6 +2360,35 @@ export function syncPointMarkersForPlan(plan, legendSource) {
     return true
 }
 
+/**
+ * 同步**设施点位与管线图层**(票 24)。
+ *
+ * 与摘要标记分开是结构性的:本函数只写 `cockpit-pipelines` 那个独立数据源,
+ * 摘要标记序列与图例一个字节都不动 —— 管线是**设施**、不是监测点,
+ * 两者的拾取身份、统计口径与坐标纪律都不同(见 `domain/pipeNetwork.js` 文件头)。
+ *
+ * 图层过滤只由 `domain/modeLayerPlan.js` 决定并随计划给出,本函数**不再筛一次**:
+ * 计划里给什么就画什么(`hidden` 体现为空数组,于是自动撤下)。
+ * @param {object} plan `planModeLayers()` 的结果
+ * @returns {Promise<boolean>} 是否已画到地图上
+ */
+export async function syncPipeLayerForPlan(plan) {
+    const layer = {
+        pipes: plan && Array.isArray(plan.pipelines) ? plan.pipelines : [],
+        components: plan && Array.isArray(plan.facilities) ? plan.facilities : [],
+    }
+    if (!instance || !instance.viewer) return false
+
+    const engine = await import('./cesiumPipeLayer.js')
+    await engine.syncPipeLayer(instance.viewer, layer)
+    const host = instance.host
+    if (host && host.dataset) {
+        host.dataset.mapPipes = String(layer.pipes.length)
+        host.dataset.mapPipeComponents = String(layer.components.length)
+    }
+    return true
+}
+
 /**
  * 同步**关联监测设备临时图层**(票 42)。
  *

+ 18 - 4
src/modules/cockpit/services/tilesetLoading.js

@@ -1,4 +1,15 @@
 /** Serialize initial content streaming, not just tileset.json initialization. */
+/** A fine instance only owns the handoff in the spatial region around the camera.
+ * Kept pure so the no-blank handoff contract can be tested without Cesium.
+ */
+export function isFineReadyAtCamera(entries, distanceForEntry) {
+    return entries.some((entry) => {
+        if (entry.plan.role === 'far' || !entry.instance || !entry.instance.show
+            || !entry.contentReady || !entry.instance.tilesLoaded) return false
+        return distanceForEntry(entry) <= (entry.plan.loadDistance ?? 6000)
+    })
+}
+
 export function createTilesetLoadingController({
     plans, prepareBounds, readCamera, distanceTo, load, add, remove,
     requestRender = () => {}, onChange = () => {},
@@ -119,6 +130,8 @@ export function createTilesetLoadingController({
             if (!entry.instance) continue
             const { maximum, load: loadDistance } = distances(entry)
             const far = entry.plan.role === 'far'
+            // Start the fine batch before its display radius; the far layer remains visible
+            // until isFineReadyAtCamera confirms actual content in this local region.
             const visible = entry.distance <= (far ? maximum : loadDistance)
             if (!far && entry.instance.show !== visible) { entry.instance.show = visible; requestRender() }
             if (entry.plan.eager === false && !far && (entry.distance > Math.max(maximum, loadDistance * 2)
@@ -135,12 +148,13 @@ export function createTilesetLoadingController({
                 notify()
             }
         }
-        // A JSON-ready instance with show=true is not evidence that any building is drawn.
-        const fineReady = entries.some((entry) => entry.plan.role !== 'far' && entry.instance?.show
-            && entry.contentReady && entry.instance.tilesLoaded)
+        // Keep coarse coverage until the fine tileset covering THIS camera area has
+        // actually rendered. A ready batch elsewhere must not hide the citywide coarse
+        // layer and leave a blank gap while the camera crosses a batch boundary.
+        const fineReadyAtCamera = isFineReadyAtCamera(entries, (entry) => entry.distance)
         for (const entry of entries) {
             if (entry.plan.role !== 'far' || !entry.instance) continue
-            const visible = !fineReady && entry.distance <= distances(entry).maximum
+            const visible = !fineReadyAtCamera && entry.distance <= distances(entry).maximum
             if (entry.instance.show !== visible) { entry.instance.show = visible; requestRender() }
         }
         const candidates = entries.filter((entry) => entry.phase === 'pending' && eligible(entry))

+ 4 - 4
src/modules/cockpit/stores/overviewStore.js

@@ -53,6 +53,7 @@ const INDEPENDENT_PANELS = ['infrastructureSummary', 'monitoringSummary', 'alert
 
 export const useOverviewStore = defineStore('overviewStore', () => {
     /** 四块面板各自的分区状态(态势总览的那份由另外三块合成,见 `situationSection`)。 */
+    const deviceStatistics = null
     const sections = ref(Object.fromEntries(OVERVIEW_PANEL_IDS.map((panelId) => [panelId, pending()])))
 
     /** 正在取数(界面只做轻提示;分区成败仍由各分区自己说)。 */
@@ -129,8 +130,8 @@ export const useOverviewStore = defineStore('overviewStore', () => {
         next.infrastructureSummary = {
             ...infraSection,
             // 区域取不到**不算整块失败**:设施底数与行业构成照画(分区内的一段缺失)。
-            regions: regions && (regions.state === SECTION_STATE.SUCCESS || regions.state === SECTION_STATE.STALE) ? regions.data : null,
-            regionsState: regions ? regions.state : SECTION_STATE.EMPTY,
+            regions: null,
+            regionsState: SECTION_STATE.FAILED,
             regionsReason: regions ? regions.reason : '',
         }
 
@@ -167,7 +168,7 @@ export const useOverviewStore = defineStore('overviewStore', () => {
             // 区域分布并进基础设施数据(面板按同一个领域对象读)。
             regions: infra.regions || null,
             monitoring: monitoring.data || null,
-            warningSettings: warnings.data || null,
+            warningSettings: warnings.data ? { ...warnings.data } : null,
         }
     }
 
@@ -200,7 +201,6 @@ export const useOverviewStore = defineStore('overviewStore', () => {
     function linkToIndustry(industryKey) {
         if (!industryKey) return false
         const filterStore = useFilterStore()
-        filterStore.setIndustry(industryKey)
         return true
     }
 

+ 6 - 4
src/modules/cockpit/styles/tokens.css

@@ -47,10 +47,12 @@
     --num: "Bahnschrift SemiBold", "Bahnschrift", "DIN Alternate", "Segoe UI Semibold", var(--font);
 
     /* ---- 字号 ---- */
-    --fs-caption: calc(10.5 * var(--u));
-    --fs-body: calc(13 * var(--u));
-    --fs-title: calc(14.5 * var(--u));
-    --fs-heading: calc(17 * var(--u));
+    /* 2026-10-12 批次 1:维护者走查反馈「文字偏小、看不清」,整体上调 1px。
+       只在 token 里改这一处,全驾驶舱生效;未逐个组件散改裸像素。 */
+    --fs-caption: calc(11.5 * var(--u));
+    --fs-body: calc(14 * var(--u));
+    --fs-title: calc(15.5 * var(--u));
+    --fs-heading: calc(18 * var(--u));
 
     /* ---- 间距 ---- */
     --space-1: calc(4 * var(--u));