mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-02 15:14:01 +00:00
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:
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user