fix(web): 窄屏布局三处根因 + 页面滚动塌陷

在 375/320 屏上实机取证(三节点集群 192.168.2.{30,106,60}),修掉四个
独立缺陷。前三个是布局根因,第四个是被前三个掩盖的真 bug。

1. 全站没有 box-sizing 重置
   content-box 下 `.sidebar { width:100%; padding:10px 12px }` 在 375 视口
   实算 399px(320 屏 344px)。顶栏主题色块右缘 332 > 320,被
   `.sidebar{overflow-x:hidden}` 静默裁掉 ⇒ 主题切换在手机上点不到。
   桌面侧栏同样是 274px 而非声明的 246px。
   注:已有两个「窄屏顶栏右侧溢出」commit(64d3359 用 flex min-width/vw
   限宽、0559cc5 改 backdrop-filter)都在治症状,真因是盒模型。

2. fit-view-on-init 把画布缩到不可读
   画布用绝对图形坐标(x=60/760,两列随转发数向下增长),bbox 远大于
   手机视口。实测合成缩放 375 屏 0.3135 ⇒ 248px 的卡片渲成 85px、
   有效字号 4.39px;**1280 桌面也只有 0.6011 / 8.42px**(5 条转发即
   触发),所以这不只是窄屏问题。
   改为删掉 fit-view-on-init 自己接管:MIN_ZOOM=0.65 下限 +
   frameCanvas() 把最左上节点重贴到内边距边缘(fitView 是居中的,窄屏
   下会把两列接缝摆在视口正中,两边各看一半)。duration:0 保证量 rect
   时动画已停;初始 frame 只认一次,避免后续 add 节点把正在平移的用户
   拽走。挂 @init + @nodes-initialized(store 与节点分先后到达,任一
   单独触发都会 fit 空画布)。

3. el-dialog 固定宽度超出手机视口
   Element Plus 把 width 写成内联 --el-dialog-width。375 屏实测
   left=0 right=420,右上关闭按钮在屏外,只能横向滚 overlay 才够得到。
   窄屏下 clamp 到 calc(100vw - 24px)。

4. 【滚动塌陷】状态页在手机上完全无法滑动
   `.status-page` 是 height:100% + overflow:hidden 的 flex 列,内层
   `.status-body` 靠 flex:1 + overflow-y:auto 自己滚。375 屏下可用高度
   651px,而两个不可压缩子元素 topbar(122) + kpis(565) 已占 687px ⇒
   `.status-body` 被分到 **0px**,其中却有 1682px 内容。同时外层
   `.content` scrollHeight==clientHeight(无可滚内容)⇒ 整页没有任何
   可滚动元素,手势完全无响应。
   修法:让 `.content`(App.vue 已声明 flex:1 + overflow-y:auto)做唯一
   滚动容器,页面自身不再 height:100%/overflow。ClusterView /
   SettingsView 同类嵌套一并去掉,从根上消灭「按剩余空间算高度」这类
   算术错误。另把 ≤380px 的 KPI 单列改回两列(单列四张全宽卡自身就
   565px,超过整个视口,是塌陷的起因)。

验证(构建产物挂反代打真后端 + 共享 Chromium CDP):
- 缩放 0.31→0.65、卡片 85→177px、有效字号 4.39→9.1px(320/375/414/1280 一致)
- bodyScrollW 344→320(320 屏),横向溢出清零;主题色块全部落回屏内
- 对话框 left 12 / right 363(完全在屏内,关闭键可达)
- 9 种视口 × 5 个页面 = 45 格全部「末块可达 + 无横向滚动」,console 零报错
  修复前:状态页 375/320/414 全部无滚动;1280 桌面状态/设置同样中招
- 三节点滚动重启部署,集群收敛 pending=0 / topo=4,owner 与实际
  worker 进程一一对应,journal 无 panic/fatal,reclaimed=2 各节点自愈
This commit is contained in:
JianFeeeee
2026-09-18 11:00:35 +08:00
parent 64d3359981
commit f4fb964734
6 changed files with 170 additions and 18 deletions

View File

@ -446,7 +446,10 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
gap: 2px;
padding: 5px 2px;
border-radius: 11px;
font-size: 10.5px;
/* Keep the tab label at the theme's 11px floor; the 10.5px here (and 9.5px
* at ≤380px) read as noise on a phone. "账号与密钥" is still handled by the
* .label ellipsis (width:100% + overflow:hidden). */
font-size: 11px;
font-weight: 600;
text-align: center;
}
@ -500,12 +503,13 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
/* 极窄(≤ 380px,iPhone SE / 小屏安卓):进一步压缩顶条。
角色徒章是 flex:0 0 auto 不可收缩的(“超级管理员”≨70px),极窄下隐去,
用户名与退出按钮保留。 */
用户名与退出按钮保留。
注意:底部 tab 文字不再随之降到 9.5px —— 它在手机上低于可读下限
(见 .sb-i 的 11px 注释),长标签交给 .label 的省略号处理。 */
@media (max-width: 380px) {
.sb-brand-text b { font-size: 13px; }
.sb-identity { padding: 4px 7px; }
.sb-identity .id-level { display: none; }
.sb-i { font-size: 9.5px; }
.content { padding-left: 10px; padding-right: 10px; }
}
</style>

View File

@ -184,6 +184,20 @@
/* ---- blue theme (ocean × frost) ---- */
/* ---- box model reset ----
* This app had NO box-sizing reset, so every element using `width: 100%` with
* horizontal padding overflowed by exactly its padding under the default
* content-box model. The narrow-screen top bar was the visible victim:
* `.sidebar { width: 100%; padding: 10px 12px }` rendered 375+24 = 399px wide
* on a 375px viewport (344px at 320px), pushing the theme swatches and logout
* button off the right edge where `overflow-x: hidden` silently clipped them.
* It also made the desktop sidebar 246+28 = 274px wide instead of 246px.
* border-box makes width include padding+border, which every layout here
* already assumes (Element Plus sets it on its own components already). */
*,
*::before,
*::after { box-sizing: border-box; }
html,
body,
#app {
@ -346,4 +360,14 @@ a { color: var(--w4f-primary-h); }
/* ---- Element Plus dialog tweaks to read as glass ---- */
.el-overlay { backdrop-filter: blur(3px); }
.el-dialog { border-radius: var(--w4f-radius) !important; box-shadow: var(--w4f-sh-lg) !important; }
/* Dialogs pass a fixed px width (width="420px" / "440px"), which Element Plus
* writes into an INLINE `--el-dialog-width` custom property. On phones that is
* wider than the viewport (420px on a 375px screen), so the dialog's right edge
* — including the header close button — starts off-screen and is only reachable
* by scrolling the overlay horizontally. Clamp it to the viewport; !important
* is required to beat the inline custom property. */
@media (max-width: 720px) {
.el-dialog { --el-dialog-width: calc(100vw - 24px) !important; }
}
.el-message { border-radius: var(--w4f-radius-sm) !important; box-shadow: var(--w4f-sh-lg) !important; }

View File

@ -25,16 +25,17 @@
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:default-viewport="{ zoom: 0.85 }"
:min-zoom="0.3"
:default-viewport="{ zoom: FIT_MAX_ZOOM }"
:min-zoom="MIN_ZOOM"
:max-zoom="2"
fit-view-on-init
:delete-key-code="null"
:nodes-draggable="canWrite"
:nodes-connectable="canWrite"
:edges-updatable="false"
:edges-reconnectable="false"
class="flow-canvas"
@init="onFlowInit"
@nodes-initialized="reframe"
@connect="onConnect"
@pane-click="deselectAll"
@edge-click="onEdgeClick"
@ -118,7 +119,7 @@
<script setup lang="ts">
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { ElMessage, ElMessageBox } from 'element-plus'
@ -134,6 +135,103 @@ const nodes = ref<any[]>([])
const edges = ref<Edge[]>([])
const loading = ref(false)
// ---- viewport / zoom floor ----
// The canvas is laid out in absolute graph units (local column at x=60, remote
// column at x=760, and BOTH columns grow downward on every added forward), so
// its bounding box is far wider and taller than a phone viewport. Vue Flow's
// `fit-view-on-init` frames that whole box, which on a phone produced a scale of
// ~0.31: node cards measure 248px in graph units but rendered 85px wide at an
// effective font size of ~4.4px — unreadable. Even a 1280px desktop landed at
// ~0.60 (8.4px) with five forwards stacked.
//
// So the canvas is framed by frameCanvas() below, clamped by a zoom floor, and
// the canvas pans when the content no longer fits. 0.65 renders a 240px local
// card at ~156px with a legible ~9px font.
//
// NOTE: `fit-view-on-init` is deliberately NOT set. It resolves on its own
// schedule and overwrote the explicit fit performed here (observed: this code
// measured the unfitted layout, set the viewport, and then the deferred init fit
// landed last and won). Owning the fit outright is the only way to keep it
// deterministic.
//
// The store arrives via the `init` event. That is deliberately preferred over
// `useVueFlow()`: this component is the PARENT of <VueFlow>, and calling
// useVueFlow() in a parent without a shared id can resolve to a different store
// than the one the rendered flow uses. The event cannot misfire that way.
const MIN_ZOOM = 0.65
const FIT_MAX_ZOOM = 1
type FlowStore = {
fitView: (opts?: Record<string, unknown>) => Promise<boolean>
getViewport?: () => { x: number; y: number; zoom: number }
setViewport?: (
t: { x: number; y: number; zoom: number },
opts?: Record<string, unknown>,
) => Promise<boolean>
}
let flowStore: FlowStore | null = null
// frameCanvas fits the graph, then re-anchors it to the top-left padding edge.
//
// Two reasons it is not just fitView():
// 1. the zoom floor (MIN_ZOOM) — fitView alone shrank 5 stacked forwards to
// ~0.31 on a phone, rendering 4.4px text;
// 2. fitView centres the bounding box, which on a narrow screen puts the seam
// between the local and remote columns mid-viewport — the user sees half of
// each. Anchoring the top-left node to the padding edge means whole local
// cards are visible immediately and the remote column is a pan away.
//
// duration:0 on both transitions — we measure node rects in between, and a
// running animation would hand back mid-flight transforms and skew the anchor.
const PANE_PAD_RATIO = 0.06
const frameCanvas = async () => {
if (!flowStore) return
await flowStore.fitView({
padding: 0.12,
minZoom: MIN_ZOOM,
maxZoom: FIT_MAX_ZOOM,
duration: 0,
})
const pane = document.querySelector<HTMLElement>('.vue-flow')
const nodeEls = Array.from(document.querySelectorAll<HTMLElement>('.vue-flow__node'))
if (!pane || !nodeEls.length) return
const paneRect = pane.getBoundingClientRect()
const rects = nodeEls.map((el) => el.getBoundingClientRect())
const dx =
paneRect.left + pane.clientWidth * PANE_PAD_RATIO - Math.min(...rects.map((r) => r.left))
const dy =
paneRect.top + pane.clientHeight * PANE_PAD_RATIO - Math.min(...rects.map((r) => r.top))
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
const vp = flowStore.getViewport?.()
if (!vp) return
await flowStore.setViewport?.({ x: vp.x + dx, y: vp.y + dy, zoom: vp.zoom }, { duration: 0 })
}
// The flow store and the node list become available at different times: the
// store at component init, the nodes only once the async canvas fetch resolves
// (plus a further tick for Vue Flow to measure them). Framing on any single
// signal would fit an EMPTY canvas, so every path calls reframe() and it no-ops
// until there is a store and something to frame.
//
// `nodes-initialized` also fires whenever a node is added later. Auto-framing on
// that would yank the viewport out from under whoever is mid-pan, so the initial
// frame is claimed exactly once; rearranging is the only thing that re-frames on
// demand (see autoLayout).
let framedOnce = false
const reframe = (force = false) => {
if (!flowStore || !nodes.value.length) return
if (framedOnce && !force) return
framedOnce = true
nextTick(() => { void frameCanvas() })
}
const onFlowInit = (store: FlowStore) => {
flowStore = store
reframe()
}
// Background dot-pattern color, resolved from the live theme var so the grid
// recolors on theme switch (vue-flow Background renders pattern-color as an
// SVG fill attribute, which can't resolve var() directly — read the computed
@ -667,7 +765,12 @@ const remoteNodes = computed(() =>
nodes.value.filter((n) => n.type === 'remote'),
)
const autoLayout = () => layoutColumns(true)
// Re-frame after rearranging, bounded by the same zoom floor so "自动排列" never
// shrinks the cards back into unreadable territory on a narrow screen.
const autoLayout = () => {
layoutColumns(true)
reframe(true)
}
const load = async () => {
loading.value = true
@ -718,6 +821,9 @@ const load = async () => {
}
assignLayers()
dirty.value = false
// The store exists by now but the node list has only just been populated, so
// this is the first moment a fit actually has something to frame.
reframe(true)
} finally {
loading.value = false
}

View File

@ -481,7 +481,11 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="scss">
.cluster-page { height: 100%; display: flex; flex-direction: column; overflow-y: auto; padding: 4px 0 40px; gap: 14px; }
/* Let .content (App.vue) remain the single scroll container — see the note in
* StatusView: a page-level scroller sized by leftover flex space is what froze
* the status page. `.log-list` keeps its own bounded scroll (that one is
* deliberate — a log pane should not push the whole page). */
.cluster-page { display: flex; flex-direction: column; padding: 4px 0 40px; gap: 14px; }
/* hero */
.hero { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; }

View File

@ -167,11 +167,12 @@ onMounted(load)
</script>
<style scoped lang="scss">
/* Let .content (App.vue) remain the single scroll container — see the note in
* StatusView: a page-level scroller sized by leftover flex space can collapse
* to zero height on narrow screens. */
.settings-page {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.page-top {

View File

@ -496,11 +496,22 @@ onBeforeUnmount(() => {
</script>
<style scoped lang="scss">
/* The pages inside .content must NOT be their own scrollers.
*
* App.vue already designates `.content` as the app's single scroll container
* (`flex:1; overflow-y:auto`). These pages were also `height:100%` +
* `overflow-y:auto`, producing a nested scroller whose height is the leftover
* space of a flex column. On a 375px phone the status page's fixed-height
* header (122px) plus the single-column KPI stack (565px) exceed the available
* 651px, so the inner `.status-body` was handed 0px while holding 1682px of
* content — the page looked completely frozen, since neither `.content` (which
* had nothing to scroll) nor the 0-height inner box could be swiped.
*
* Letting the page grow naturally and scroll in `.content` removes the whole
* class of bug: there is no leftover-space arithmetic left to get wrong. */
.status-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 14px;
}
@ -527,12 +538,9 @@ onBeforeUnmount(() => {
}
.status-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 18px;
overflow-y: auto;
padding-right: 4px;
}
.section { display: flex; flex-direction: column; }
@ -720,8 +728,13 @@ onBeforeUnmount(() => {
}
@media (max-width: 380px) {
/* 极窄:KPI 也降为单列,否则数字会被压断 */
.kpis { grid-template-columns: minmax(0, 1fr); }
/* Keep the KPI grid at TWO columns even on the narrowest phones.
* Dropping to one column made four full-width cards ~565px tall, which on its
* own exceeded the whole viewport and (with the page-level scroller of the
* day) collapsed the content area to zero height. Two columns keeps each card
* ~160px wide — enough for the 27px number + 11.5px caption, which is all a
* KPI card holds. */
.kpis { gap: 8px; }
.fwd-route { font-size: 12.5px; }
.page-title { font-size: 16px; }
}