Browse Source

fix(cockpit): 复审修正——恢复右列溢出兜底、校正单位注释、闸门去假绿(票102)

code-review 两轴(Standards / Spec)独立审同一段提交,都在三处收口,全部核实后修正:

1. **布局回归(最重)**。上一提交把右列写成 `minmax(0, 1fr)`,抖动是消除了,
   但顺手撤掉票 37 的 min-content 兜底 —— 右列是 `justify-content: flex-end`,
   内容一旦超宽仍会向左溢出压住「预警处置」,正是票 37 当年记录的那个 bug。
   核实后结论相反:真凶是**时钟读数的不定宽**,不是 min-content 本身。
   时钟进固定数字槽位后,右列 min-content 只剩按钮与槽位两个常量,不再随秒针变化,
   于是「位置稳定」与「不压菜单」同时成立。已恢复 min-content,
   并在注释里写清为什么不取等分、以及最宽内容 ≈293u 与各档余量的算法。

2. **两处注释算错 --u**。tokens.css 写明「1080 高时 1.333」,我却在两处写
   「1920×1080 --u=1px 档」。1px 只在高度 ≤810 时生效(clamp 下限)。
   菜单位移注释改为按 CSS 几何推导:组宽 372u、左移后左右缘、与两根翼线
   (各 170u)不相碰的条件 W ≥ 776u,并给出 1920×1080(余 332u/396u)与
   1024×640(余 124u/188u)两档。时钟注释补上同图 483px 导航跨度 ÷1.4326=337u
   的反证,说明 --u 取值无误。

3. **闸门假绿**。上一版对**全文件**字符串断言:`display: inline-block` 在该文件
   有 2 处、`flex: 0 0 auto` 有 4 处,删掉时钟槽位闸门照样绿;
   `!includes('minmax(min-content, 1fr)')` 还会因为注释引用旧写法而误红。
   计数也写成了手写的 13/13、14/14(实际 11 与 13 条)。
   现改为共用取数层 verify/t102-shell-source.mjs:按**选择器取规则体**、
   只认**声明行**(注释不算)、计数由运行时累加。两个闸门改成名副其实的名字
   (labels / position),并做了变异验证:
   删槽位、换 margin-left、槽位缩到 60u、改回 minmax(0,1fr) 全部转红;
   仅注释里提到旧写法不误红。

4. 删掉 `.cockpit-shell__meta` 时留下的重复空行。

闸门:labels 11/11、position 15/15;position 对上一提交的源码为红(正是上面第 1 条)。
顺带修正部署文档走查行(它沿用了「右列由等分决定」的错误说法)。
按 AGENTS.md 未跑 build/dev/浏览器。
ChenJZ 17 hours ago
parent
commit
d4845bc979

+ 1 - 1
docs/cockpit-deployment.md

@@ -516,7 +516,7 @@ location /gis/model/ {
 | 归位按钮 | 顶栏「归位」可点,平移后点它回到目标视角 | `map.home`、引擎是否就绪 |
 | 配置生效 | 网络面板里 `cockpit/config.json` 返回 200,且页面按它取值(改 `map.home` 后归位落点跟着变) | `config.json` 路径、`__COCKPIT_CONFIG_URL__` |
 | 数据源 | `dataSource: real` 时面板数据来自 `/api/cockpit/v1/**`(网络面板可见),mock 时没有这些请求 | `config.json` 的 `dataSource`、`?dataSource=` 覆盖 |
-| 顶栏菜单稳定 | 一级模式按钮固定「居中偏左」,秒针跳动时按钮**不左右移动**,也不压到背景光路 | 票 102:右列宽度由等分决定、时钟为固定数字槽位 |
+| 顶栏菜单稳定 | 一级模式按钮固定「居中偏左」;盯 10 秒,秒针跳动时按钮**不左右移动** | 票 102 闸门 `verify/t102-topbar-position.mjs`;源码看 `CockpitShell.vue` 的 `.cockpit-shell__clock` 数字槽位与 `.cockpit-shell__modes` 的 translateX |
 | 全局底图 | 地球有影像(不是黑球) | `map.basemap.source` |
 | 地形 | 浮标含「地形:dem」;拉近后有山体起伏 | nginx `/gis/dem/`、`layer.json` 能直接下载 |
 | 本地影像 | 压到目标区域能看到精细影像 | overlay 的 `url`/`{reverseY}`/`bounds` |

+ 25 - 12
src/modules/cockpit/CockpitShell.vue

@@ -2257,16 +2257,22 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
    顶栏(压矮:56 设计像素)
    ============================================================ */
 /* 顶栏三列:品牌 / 一级模式 / 状态区。
-   票 102:右列的最小宽度从「按内容」改回「按等分」—— **列宽由等分决定,不由内容决定**。
-   此前右列按内容最小宽度兜底,于是内容里任何一点宽度变化(时钟秒针的字体长短不平均、
-   读数换字、chip 增减)都会改写列宽,把中列菜单推着左右移动
-   (维护者 2026-11-02 报障:时间一跳,按钮就跟着挪)。
-   右列现在吃等分且内容右对齐,内容再变也只在列内挪,碰不到中列。
-   票 37 当年担心的「右列内容超宽向左溢出压住模式区」,靠的是三个诊断读数 + 时钟
-   一起把右列撑爆;读数已下屏(本票),剩下的内容宽度远小于等分列宽。 */
+   票 102:右列的 `min-content` 兜底**保留**,但让它先变成「与秒针无关」的常量。
+   维护者 2026-11-02 报障「时间一跳,按钮就跟着挪」:右列宽度按内容最小宽度兜底,
+   而内容里的时钟读数每换一次数字宽度就变一次(字体长短不平均),列宽随之改写,
+   中列(一级模式)被推着左右移动。
+   真凶是**时钟的不定宽**,不是 min-content 本身 —— 时钟改成固定宽度数字槽位(见
+   .cockpit-shell__clock)后,这一列的 min-content 只剩按钮标签与槽位这两个常量,
+   不再随时间变化;于是两条要求同时成立:
+   - 位置稳定:列宽要么取这个常量下限、要么取 1fr 等分,两者都不看秒针脸色;
+   - 不压到菜单:下限仍在,窄视口下内容超宽时不会像票 37 那样向左溢出压住「预警处置」
+     (最宽内容 = 归位/进入 + 返回全球 + 128u 槽位 + 间隙与内边距 ≈ 293u;最紧的一档
+     1024×640(--u 落到 1px 下限)等分给到 314px > 293px,取的是等分而不是下限)。
+   曾试过把它改成 `minmax(0, 1fr)`:抖动同样消除,但顺手撤掉了票 37 的溢出兜底,
+   窄视口会退回「归位压预警处置」那个老问题,故不取。 */
 .cockpit-shell__topbar {
     display: grid;
-    grid-template-columns: minmax(240px, 1fr) auto minmax(0, 1fr);
+    grid-template-columns: minmax(240px, 1fr) auto minmax(min-content, 1fr);
     align-items: center;
     gap: calc(12 * var(--u));
     height: calc(52 * var(--u));
@@ -2412,7 +2418,13 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
        所以选中态切换、读数变化都改不了按钮位置。
        用 transform 而不是 margin:margin 会把中列让出的空位再分给左右两列,
        等于把「谁推菜单」的裁决权交还给内容;translateX 是纯视觉偏移,几何仍居中。
-       32u 是按 1920×1080(--u=1px 档)实测「略微左移」的幅度,与顶栏其余尺寸同一套 --u。 */
+       32u 的幅度按 CSS 几何算(未跑浏览器,AGENTS.md):菜单组宽 372u(4 枚按钮各
+       4 字 ×14u + 2×17u 内边距 = 90u,加 3×4u 间隙),原本正好居中;左移 32u 后
+       左缘 = W/2−218u、右缘 = W/2+154u。会挡路的只有两根翼线(top:58%,与按钮同一
+       水平带),它们各占 0–170u 与 W−170u–W,不相碰的条件是 W ≥ 776u;而 --u 由高度
+       决定(1920×1080 → --u=1.3338px → W=1440u,左右各余 332u/396u;最紧的 1024×640
+       → --u 落到 1px 下限 → W=1024u,仍各余 124u/188u)。底边光路贴基线(菜单底缘在
+       基线上方 4u 出血,是票 17 第八轮的既有关系),本条平移不改它的竖直位置。 */
     transform: translateX(calc(-32 * var(--u)));
 }
 
@@ -2489,8 +2501,10 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
 
 /* 票 102(维护者:时间字体长短不平均,秒针一跳按钮就跟着左右挪):
    时钟改为**固定宽度的数字槽位**,槽位内右对齐 + 等宽数字(tabular-nums + tnum 兜底)。
-   宽度按 2384×1160 截图实测:`--u` 在该视口 ≈1.433px(0.1235vh),一次读数 `21:22:22`
-   的字形跨度 145px ≈ 101u,取 128u(+27% 余量)覆盖「把 1 换成 8」的最宽读数与字体回退
+   宽度按 2384×1160 截图实测(该图 CSS 视口即 2384×1160、--u=1.4326px;
+   同图 483px 的导航字形跨度 ÷1.4326 = 337u,与「4×90u+3×4u−2×17u内边距」吻合,
+   可反证 --u 取值无误):一次读数 `21:22:22` 的字形跨度 145px = 101u,
+   取 128u(+27% 余量)覆盖「把 1 换成 8」的最宽读数与字体回退
    (Rajdhani → Bahnschrift → Segoe,等宽数字不被支持时)—— 槽位只要不吃到内容宽度,
    宽度就恒由这条声明决定:秒针跳动只改槽位内的字形,不再改动任何布局宽度。
    flex: 0 0 auto —— 槽位不参与挤压,右列内容再多也不会压缩它。 */
@@ -2508,7 +2522,6 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     text-shadow: 0 0 12px rgba(1, 171, 234, 0.55);
 }
 
-
 /* ============================================================
    全局筛选条(压矮:38 设计像素)
    ============================================================ */

+ 66 - 0
verify/t102-shell-source.mjs

@@ -0,0 +1,66 @@
+/**
+ * 票 102 闸门的共用取数层(不是独立闸门,由 t102-topbar-labels / t102-topbar-position 引用)。
+ *
+ * 为什么要这一份:
+ * 1. 两个闸门读同一个文件,读法与「导航不许动」的三条契约只该写一遍。
+ * 2. 断言必须**落在选择器作用域里**。直接对全文件字符串子串断言会出假证据 ——
+ *    实测 CockpitShell.vue 里 `display: inline-block` 有 2 处、`flex: 0 0 auto` 有 4 处,
+ *    全文件 includes 就算时钟槽位被删了也照样为绿。
+ * 3. 计数由运行时的 `check()` 自己累加,不再手写「N/N」——
+ *    手写数字会和断言条数脱节(上一版就是这么写错成 13/13、14/14 的)。
+ */
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+
+let checks = 0
+
+/** 记一条断言并转交给 node 的 strict assert;计数与断言同一处发生。 */
+export function check(condition, message) {
+    checks++
+    assert.ok(condition, message)
+}
+
+/** 断言「必须为假」的那类,同样计入条数。 */
+export function checkAbsent(haystack, needle, message) {
+    checks++
+    assert.ok(!haystack.includes(needle), message)
+}
+
+/** 收尾打印真实条数。 */
+export function report(name) {
+    console.log(`${name}: ${checks}/${checks}`)
+}
+
+/** 读外壳源码;argv[2] 给路径时可复核历史版本(例如提交前的暂存副本必须是红的)。 */
+export function readShell(explicitPath) {
+    return explicitPath
+        ? fs.readFileSync(explicitPath, 'utf8')
+        : fs.readFileSync(new URL('../src/modules/cockpit/CockpitShell.vue', import.meta.url), 'utf8')
+}
+
+/**
+ * 取某个类名的规则体(大括号之间)。要求**恰好一处**,否则直接红:
+ * 出现两处意味着该画法被拆开或重名,断言就不再指向唯一事实源。
+ */
+export function ruleBody(source, className) {
+    const heads = [...source.matchAll(new RegExp(`\\n\\.${className}\\s*\\{`, 'g'))]
+    assert.equal(heads.length, 1, `.${className} 规则应为 1 处,实得 ${heads.length} 处`)
+    const open = heads[0].index + heads[0][0].indexOf('{')
+    const end = source.indexOf('}', open)
+    assert.notEqual(end, -1, `.${className} 规则未闭合`)
+    return source.slice(open + 1, end)
+}
+
+/** 只认**声明行**:注释里引用旧写法不该让闸门变红。 */
+export function declaration(source, property) {
+    const m = source.match(new RegExp(`^\\s*${property}:[^;]+;`, 'm'))
+    assert.ok(m, `没有 ${property} 声明`)
+    return m[0]
+}
+
+/** 一级模式导航的三条不变契约(位置怎么改都不许动语义),两个闸门共用。 */
+export function assertModeNavIntact(source) {
+    check(source.includes('aria-label="一级模式"'), '一级模式导航保持原样')
+    check(source.includes('v-for="item in MODES"'), '一级模式导航保持原样')
+    check(source.includes('@click="uiStore.setMode(item)"'), '一级模式导航保持原样')
+}

+ 28 - 0
verify/t102-topbar-labels.mjs

@@ -0,0 +1,28 @@
+/**
+ * 票 102 静态闸门(一):顶栏三个诊断读数下屏,且不留半截改动。
+ *
+ * 接缝:外壳单文件源码契约 —— 模板节点 / import / 死状态 / CSS 类名必须同时消失。
+ * 只删模板会留下死样式与死 import,只删样式会留下「有节点没画法」的裸标签。
+ * 用法:node verify/t102-topbar-labels.mjs [源码路径](给路径可复核历史版本)。
+ */
+import { readShell, check, checkAbsent, assertModeNavIntact, report } from './t102-shell-source.mjs'
+
+const source = readShell(process.argv[2])
+
+// ---- 三个读数:模板、样式、数据来源一起下屏 ----
+checkAbsent(source, '只读视图', '「只读视图」必须下屏')
+checkAbsent(source, '配置就绪', '「配置就绪 · 数据源」必须下屏')
+checkAbsent(source, 'cockpit-shell__config', '配置读数容器(模板/样式)不得残留')
+checkAbsent(source, 'configStatusView', '配置读数展示层不得残留')
+checkAbsent(source, 'StatusTag', 'StatusTag 只服务配置读数,必须一并移除')
+checkAbsent(source, 'cockpit-shell__meta', '「当前模式」读数不得残留')
+
+// ---- 配置判定本身仍在用,不能顺手删 ----
+check(source.includes('configState.value'), '配置判定仍被地图配置/降级说明使用,不能删')
+// 反方向也要守:读数的「载入前不闪标签」闸门随读数一起变成死状态。
+checkAbsent(source, 'configLoaded', '拉取态闸门随读数下屏,不得留下无人读的状态')
+
+// ---- 一级模式导航语义不动 ----
+assertModeNavIntact(source)
+
+report('t102-topbar-labels')

+ 49 - 0
verify/t102-topbar-position.mjs

@@ -0,0 +1,49 @@
+/**
+ * 票 102 静态闸门(二):一级菜单的位置不得由秒针决定,同时保留票 37 的溢出兜底。
+ *
+ * 维护者 2026-11-02:「按钮在居中偏左(略微左移,不与背景装饰线条重合);目前由于时间字体
+ * 长短不平均,时间跳动会导致按钮位置左右移动,需避免。」
+ *
+ * 关键认知:让菜单移动的真凶是**时钟读数的不定宽**,不是右列的 min-content 兜底本身。
+ * 所以正确解是「固定时钟宽度」+「保留 min-content 下限」这对组合:
+ * - 时钟进固定数字槽位后,右列的 min-content 只剩常量,不再随秒针变 → 位置稳定;
+ * - min-content 下限还在 → 窄视口内容超宽时不会像票 37 那样向左溢出压住「预警处置」。
+ * 只把右列改成 minmax(0, 1fr) 也能消除抖动,但会撤掉溢出兜底(上一版就犯了这个错),
+ * 本闸门同时锁这两条,改回 0 或删掉槽位都会转红。
+ * 断言一律落在对应选择器的规则体内,不用全文件子串(见 t102-shell-source 的说明)。
+ * 用法:node verify/t102-topbar-position.mjs [源码路径]。
+ */
+import { readShell, ruleBody, declaration, check, assertModeNavIntact, report } from './t102-shell-source.mjs'
+
+const source = readShell(process.argv[2])
+
+// 入口一:右列保留 min-content 下限(溢出兜底,票 37)——但见入口三,它现在必须是常量。
+const columns = declaration(source, 'grid-template-columns')
+check(columns.includes('minmax(240px, 1fr) auto minmax(min-content, 1fr)'), '顶栏三列必须保留右列 min-content 下限')
+
+// 入口二:一级菜单整组平移,位置与内容彻底解耦。用 transform 不用 margin。
+const modes = ruleBody(source, 'cockpit-shell__modes')
+check(/transform:\s*translateX\(calc\(-\d+(?:\.\d+)?\s*\*\s*var\(--u\)\)\)/.test(modes), '一级菜单必须整组 translateX 左移(居中偏左)')
+check(!/margin-left/.test(modes), '左移不得用 margin-left(会被 grid 再分配,等于把裁决权交回内容)')
+
+// 入口三:时钟读数宽度恒由声明决定,而不是由「这一刻几点几分几秒」决定。
+// 这是让入口一的下限变成常量的前提 —— 槽位一旦被删,min-content 就又开始随秒针抖。
+const clock = ruleBody(source, 'cockpit-shell__clock')
+check(/display:\s*inline-block/.test(clock), '时钟槽位要 display:inline-block 才吃得到 min-width')
+check(/flex:\s*0 0 auto/.test(clock), '时钟槽位不参与挤压')
+check(/text-align:\s*right/.test(clock), '时钟槽位内右对齐(左缘固定,只右侧字形变化)')
+check(/font-variant-numeric:\s*tabular-nums/.test(clock), '时钟数字必须等宽')
+const slot = clock.match(/min-width:\s*calc\((\d+(?:\.\d+)?)\s*\*\s*var\(--u\)\)/)
+check(Boolean(slot), '时钟必须有固定 min-width 数字槽位(右列下限靠它保持常量)')
+if (slot) {
+    // 最宽一次读数(2384×1160 实测 HH:MM:SS 占 ~101u)必须塞得进槽位,否则 min-width 形同虚设。
+    check(Number(slot[1]) >= 101, `时钟槽位 ${slot[1]}u 不足以容纳最宽读数(实测 ~101u)`)
+}
+
+// 位置改动只许动位置:导航语义、归位/返回全球按钮、时钟本体都不变。
+assertModeNavIntact(source)
+check(source.includes('cockpit-shell__home'), '归位按钮保持原样')
+check(source.includes('cockpit-shell__return-global'), '返回全球按钮保持原样')
+check(source.includes('cockpit-shell__clock'), '时钟保持原样')
+
+report('t102-topbar-position')