Explorar el Código

refactor(cockpit): consolidate toolbar status and right-align refresh

Kazerin hace 1 semana
padre
commit
777f0614db

+ 4 - 5
.scratch/cockpit-shell-panel-layout/verify/t15-websocket.mjs

@@ -397,19 +397,18 @@ function fakeTimers() {
 
     // 反向证据:**显式刷新**换新快照时丢弃推送缓存,让这一份快照说了算
     // (否则一条较旧的推送会盖掉刚取回的新状态 —— 那正是"用旧值覆盖新值")。
-    // 注意 `refresh()` 是**整份换快照并回到第 1 页**,所以 MP-003(第 2 页的对象)
-    // 刷新后本来就不该在图上;要验的是"翻回它时按快照值显示,不再带 push 标记"。
+    // 注意:`refresh()` 是**整份换快照并自动连翻到完成**,所以 MP-003 刷新后会在对象列表里;
+    // 要验的是它按快照值显示、不再带 push 标记。
     await mapStore.refresh()
     const page1AfterRefresh = mapStore.summary.objects.map((item) => `${item.code}:${item.status}`)
-    await mapStore.advance()
     const mp003AfterRefresh = mapStore.summary.objects.find((item) => item.code === 'MP-003')
     check(
         'E7d-refresh-lets-snapshot-win',
-        page1AfterRefresh.length === 2 &&
+        page1AfterRefresh.length >= 3 &&
             !!mp003AfterRefresh &&
             mp003AfterRefresh.status === POINT_STATUS.WARNING &&
             mp003AfterRefresh.statusSource === undefined,
-        `刷新后第 1 页 ${JSON.stringify(page1AfterRefresh)}(回到第 1 页)→ 再翻到 MP-003:${mp003AfterRefresh && mp003AfterRefresh.status}(快照值 WARNING、无 push 标记)= 显式重新取数以快照为准`,
+        `刷新后对象 ${JSON.stringify(page1AfterRefresh)} → MP-003=${mp003AfterRefresh && mp003AfterRefresh.status}(快照值 WARNING、无 push 标记)= 显式重新取数以快照为准`,
     )
 }
 

+ 68 - 0
.scratch/cockpit-shell-panel-layout/verify/t34-status-summary.mjs

@@ -0,0 +1,68 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { parse } from '@vue/compiler-sfc'
+import { baseParse } from '@vue/compiler-dom'
+
+// Focused static/logic checks for the toolbar request; no server or browser.
+const source = readFileSync(new URL('../../../src/modules/cockpit/CockpitShell.vue', import.meta.url), 'utf8')
+const { descriptor, errors } = parse(source)
+assert.deepEqual(errors, [], 'Vue SFC must parse')
+const ast = baseParse(descriptor.template.content)
+const elements = []
+function visit(node, parent = null) {
+    if (node.type === 1) elements.push({ node, parent })
+    for (const child of node.children || []) visit(child, node.type === 1 ? node : parent)
+}
+visit(ast)
+const has = (node, name) => node.props.some((p) => (p.type === 6 && p.name === name) || (p.type === 7 && p.name === 'bind' && p.arg?.content === name))
+const cls = (node, name) => node.props.some((p) => p.type === 6 && p.name === 'class' && p.value.content.split(/\s+/).includes(name))
+const find = (predicate) => elements.find(({ node }) => predicate(node))?.node
+function within(node, ancestor) {
+    while (node) {
+        if (node === ancestor) return true
+        node = elements.find((entry) => entry.node === node)?.parent
+    }
+    return false
+}
+const scope = find((n) => cls(n, 'cockpit-shell__scope'))
+const summary = find((n) => cls(n, 'cockpit-shell__status-summary'))
+const details = find((n) => has(n, 'data-status-details'))
+const refresh = find((n) => has(n, 'data-map-refresh'))
+assert.equal(elements.filter(({ node }) => has(node, 'data-status-toggle')).length, 1)
+assert.ok(within(details, summary), 'details belong to summary')
+for (const marker of ['data-map-summary-phase', 'data-section-board', 'data-ws-state', 'data-freshness']) {
+    assert.ok(within(find((n) => has(n, marker)), details), `${marker} belongs to details`)
+}
+assert.equal(elements.filter(({ node }) => has(node, 'data-map-refresh')).length, 1)
+assert.ok(!within(refresh, details), 'refresh is outside details')
+assert.equal(scope.children.filter((n) => n.type === 1).at(-1), refresh, 'refresh is final toolbar control')
+assert.ok(!descriptor.template.content.includes('区内限位'))
+assert.ok(!descriptor.template.content.includes('{{ fogText }}'))
+assert.ok(!descriptor.template.content.includes('推送 · {{ runtimeStore.statusText }}'))
+
+// Execute the actual computed getters against controlled state (not token-presence checks).
+function getter(name) {
+    const body = source.match(new RegExp(`const ${name} = computed\\(\\(\\) => \\{([\\s\\S]*?)\\n\\}\\)`))?.[1]
+    assert.ok(body, `${name} getter exists`)
+    return new Function('runtimeStore', 'overallFreshnessState', 'overallFreshnessLabel', 'mapSummaryView', body)
+}
+const text = getter('statusSummaryText')
+const tone = getter('statusSummaryTone')
+function checkState(view, phase, overall, expected, expectedTone) {
+    const runtime = { freshness: { view, text: `fresh:${view}`, lastSuccessClock: '12:00:00' }, status: { state: 'open' }, statusText: 'ws:open' }
+    const args = [runtime, { value: overall }, { value: 'partial warning' }, { value: { phase, label: `map:${phase}` } }]
+    assert.equal(text(...args), expected)
+    assert.equal(tone(...args), expectedTone)
+}
+checkState('current', 'complete', 'partial', 'partial warning', 'is-warning')
+for (const view of ['empty', 'stale', 'unavailable']) checkState(view, 'complete', 'current', `fresh:${view}`, 'is-warning')
+for (const phase of ['idle', 'loading', 'empty', 'failed', 'expired']) checkState('current', phase, 'current', `map:${phase}`, 'is-warning')
+checkState('current', 'complete', 'current', '更新 12:00:00', 'is-ok')
+const style = descriptor.styles.map((s) => s.content).join('\n')
+const rule = (selector) => style.slice(style.indexOf(`${selector} {`)).split('}')[0]
+assert.match(rule('.cockpit-shell__scope-left'), /overflow-x:\s*auto/)
+assert.match(rule('.cockpit-shell__status-details'), /max-height:/)
+assert.match(rule('.cockpit-shell__status-details'), /overflow:\s*auto/)
+assert.match(rule('.cockpit-shell__scope'), /overflow:\s*visible/)
+assert.match(rule('.cockpit-shell__scope'), /z-index:\s*3/)
+console.log('PASS toolbar AST nesting, rightmost refresh, removed diagnostics, summary state cases, and overflow guards')

+ 177 - 70
src/modules/cockpit/CockpitShell.vue

@@ -149,11 +149,33 @@
             </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):「加载下一页 / 刷新快照」两个手动按钮移除——
-                    进入系统/筛选变化后地图摘要**自动分批加载到完成**;这里只留右上角一枚
-                    🔄 手动刷新(换新快照、从第 1 页自动重载)。小胶囊的状态点与短文案保留:
-                    它仍是走查断言面(data-map-summary-*),也是人工看得见的装载进度。
+                    票 14(决策 02 的 Q24/Q25):地图摘要进入系统/筛选变化后**自动分批加载到完成**。
+                    它作为状态详情的一行保留 `data-map-summary-*` 断言面;手动刷新按钮移到状态摘要
+                    右侧独立位置,避免和范围/行业/视图选择争抢横向空间。
                 -->
                 <div
                     class="cockpit-shell__map-summary"
@@ -165,18 +187,6 @@
                     <span class="cockpit-shell__map-summary-text" data-map-summary-label :title="mapSummaryView.detail">
                         {{ mapSummaryView.label }}
                     </span>
-                    <button
-                        type="button"
-                        class="cockpit-shell__chip cockpit-shell__map-next"
-                        data-map-summary-action
-                        data-map-refresh
-                        :disabled="mapStore.busy"
-                        aria-label="手动刷新快照"
-                        title="换到后端最新快照,并自动从第 1 页加载到完成"
-                        @click="refreshMapSnapshot"
-                    >
-                        🔄
-                    </button>
                 </div>
 
                 <!--
@@ -225,43 +235,6 @@
                 <span v-if="areaNotice" class="cockpit-shell__chip cockpit-shell__locate" data-area-notice role="status">
                     {{ areaNotice }}
                 </span>
-                <!-- 票 37:区内状态读数(走查断言面;人工也能一眼看出限位生效了)。 -->
-                <span
-                    v-if="areaLocked"
-                    class="cockpit-shell__chip cockpit-shell__area"
-                    :data-area-state="areaState"
-                    :data-range-max="Math.round(areaLimits.max)"
-                    role="status"
-                >
-                    区内限位 · 最远 {{ Math.round(areaLimits.max) }} m
-                </span>
-
-                <!--
-                    票 38:自建雾的可观测读数(验收第 7 条要求「是否支持 / 是否可执行 / 当前强度 /
-                    参数 / 失败降级原因」对外可读)。**它不是「遮缝已生效」的证明** ——
-                    文案只说"视距雾/降级",不写"已遮住边缘"。
-                    降级(无深度支持 / 观察透明错位 / 用户暂关)时转成提示色,业务照常可用。
-
-                    属性名**故意用 `data-fog-state` 而不是 `data-map-fog`**:后者已经挂在
-                    地图宿主(`.map-canvas`)上表示运行期读数。同名会让 `querySelector('[data-map-fog]')`
-                    命中**宿主**(整页文本),断言与走查就会读到错误的元素 —— 本仓在票 07 上
-                    (`data-map-blocked-backend` 同名挂两处)已经栽过一次,这里不重复。
-                -->
-                <span
-                    v-if="areaLocked || fogReadout.degrade !== 'none' || fogReadout.state !== 'ready'"
-                    class="cockpit-shell__chip cockpit-shell__fog"
-                    :class="{ 'is-bad': fogReadout.state !== 'ready' }"
-                    data-fog-chip
-                    :data-fog-state="fogReadout.state"
-                    :data-fog-phase="fogReadout.phase"
-                    :data-fog-strength="fogReadout.strength"
-                    :data-fog-degrade="fogReadout.degrade"
-                    :title="fogTitle"
-                    role="status"
-                >
-                    {{ fogText }}
-                </span>
-
                 <!--
                     票 15:状态推送的连接状态(验收第 6 条「连接/重连/过期/失败可演示」)。
                     data-ws-state 是走查断言面;文案来自运行时服务,外壳不重写。
@@ -273,7 +246,7 @@
                     :title="runtimeStore.status.lastError || runtimeStore.statusText"
                     role="status"
                 >
-                    推送 · {{ runtimeStore.statusText }}
+                    {{ runtimeStore.statusText }}
                     <span v-if="runtimeStore.orderScanIntervalMs" data-order-scan-delay> · 工单扫描间隔 {{ runtimeStore.orderScanIntervalMs / 1000 }} 秒</span>
                 </span>
 
@@ -301,7 +274,21 @@
                         · {{ 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">
@@ -506,6 +493,7 @@ const monitoringStore = useMonitoringStore()
 const overviewStore = useOverviewStore()
 const eventStore = useEventStore()
 const runtimeStore = useRuntimeStore()
+const statusPanelExpanded = ref(false)
 const { mode, expandedPanelIds } = storeToRefs(uiStore)
 const { scope, industry, viewMode, pointStatus, areaState } = storeToRefs(filterStore)
 
@@ -1031,8 +1019,25 @@ const overallFreshnessLabel = computed(() => overallFreshnessText(overallFreshne
 // ---- 地图摘要渐进加载(票 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 页加载到完成**
+ * 票 14:手动刷新(状态栏最右侧的刷新动作,决策 02 的 Q25)=换新快照并**自动从第 1 页加载到完成**
  * (旧「加载下一页 / 刷新快照」两个按钮已移除;自动链归 mapStore 的装载链)。
  */
 async function refreshMapSnapshot() {
@@ -1811,11 +1816,12 @@ onBeforeUnmount(() => {
    全局筛选条(压矮:38 设计像素)
    ============================================================ */
 .cockpit-shell__scope {
+    z-index: 3;
     display: flex;
     align-items: center;
     justify-content: space-between;
     gap: calc(10 * var(--u));
-    overflow: hidden;
+    overflow: visible;
     padding: 0 calc(10 * var(--u));
     border: 1px solid rgba(58, 170, 226, 0.24);
     background: linear-gradient(180deg, rgba(7, 30, 54, 0.72), rgba(4, 15, 28, 0.88));
@@ -1828,11 +1834,26 @@ onBeforeUnmount(() => {
     align-items: center;
     gap: calc(6 * var(--u));
     white-space: nowrap;
-    /* 票 08:flex 项默认不许收缩,右块加了「地图摘要」控件后,1280 宽会把筛选条撑溢出
-       (走查实测 +202px)。允许收缩,让摘要文案的 ellipsis 真正生效。 */
     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: calc(10 * var(--u));
@@ -1893,21 +1914,112 @@ onBeforeUnmount(() => {
     letter-spacing: 0.04em;
 }
 
-/* 票 08:地图摘要的渐进加载状态 + 一个动作按钮。
-   票 33:收成小胶囊(无标题、更小字号、更窄上限),不抢一张图。 */
+/* 票 08:地图摘要仍是状态详情的一行,收起时由上层摘要按钮替代其横向占位。 */
 .cockpit-shell__map-summary {
     display: flex;
     align-items: center;
     gap: calc(5 * var(--u));
-    padding: calc(1 * var(--u)) calc(6 * var(--u));
-    border: 1px solid rgba(58, 170, 226, 0.22);
-    background: rgba(4, 20, 38, 0.4);
-    /* 空间不足时先压这一块(文案 ellipsis 收缩),不去挤范围/行业/视图按钮。 */
-    flex-shrink: 1;
     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(58, 170, 226, 0.32);
+    background: rgba(4, 20, 38, 0.72);
+    color: #b9d8e8;
+    font: inherit;
+    font-size: calc(10.5 * var(--u));
+    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(79, 216, 255, 0.75);
+    color: #fff;
+    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, #6d92ae);
+    box-shadow: 0 0 8px currentColor;
+}
+
+.cockpit-shell__status-dot.is-ok {
+    background: var(--lv4, #35e39a);
+    color: var(--lv4, #35e39a);
+}
+
+.cockpit-shell__status-dot.is-warning {
+    background: var(--lv2, #ffa63d);
+    color: var(--lv2, #ffa63d);
+}
+
+.cockpit-shell__status-chevron {
+    color: var(--cyan, #4fd8ff);
+    font-size: calc(14 * var(--u));
+    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);
+    overflow: auto;
+    padding: calc(9 * var(--u));
+    border: 1px solid rgba(79, 216, 255, 0.42);
+    background: rgba(3, 17, 32, 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: calc(15 * var(--u));
+    line-height: 1;
+}
+
 /* 状态点:加载中呼吸、完成转绿、失败/过期转红 —— 颜色语义与小胶囊共存。 */
 .cockpit-shell__map-summary-dot {
     flex: none;
@@ -1954,11 +2066,6 @@ onBeforeUnmount(() => {
     color: var(--lv4);
 }
 
-.cockpit-shell__map-next:disabled {
-    opacity: 0.5;
-    cursor: default;
-}
-
 /* 票 05:共享分区状态板。横向排列、可换行;空间不够时先压这一块,不去挤筛选按钮。 */
 .cockpit-shell__sections {
     display: flex;