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(() => {