From f4fb96473439d3699647cb30d612c093b629aee3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 11:00:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E7=AA=84=E5=B1=8F=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E4=B8=89=E5=A4=84=E6=A0=B9=E5=9B=A0=20+=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=BB=9A=E5=8A=A8=E5=A1=8C=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 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 各节点自愈 --- web/src/App.vue | 10 ++- web/src/styles/theme.css | 24 +++++++ web/src/views/CanvasView.vue | 116 +++++++++++++++++++++++++++++++-- web/src/views/ClusterView.vue | 6 +- web/src/views/SettingsView.vue | 5 +- web/src/views/StatusView.vue | 27 ++++++-- 6 files changed, 170 insertions(+), 18 deletions(-) diff --git a/web/src/App.vue b/web/src/App.vue index 05f454c..63eb788 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -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; } } diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css index 778dce6..a217e42 100644 --- a/web/src/styles/theme.css +++ b/web/src/styles/theme.css @@ -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; } diff --git a/web/src/views/CanvasView.vue b/web/src/views/CanvasView.vue index cd3f2c1..860dd30 100644 --- a/web/src/views/CanvasView.vue +++ b/web/src/views/CanvasView.vue @@ -25,16 +25,17 @@ 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([]) const edges = ref([]) 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 , 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) => Promise + getViewport?: () => { x: number; y: number; zoom: number } + setViewport?: ( + t: { x: number; y: number; zoom: number }, + opts?: Record, + ) => Promise +} +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('.vue-flow') + const nodeEls = Array.from(document.querySelectorAll('.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 } diff --git a/web/src/views/ClusterView.vue b/web/src/views/ClusterView.vue index 357e8e6..f50fc72 100644 --- a/web/src/views/ClusterView.vue +++ b/web/src/views/ClusterView.vue @@ -481,7 +481,11 @@ onBeforeUnmount(() => {