Files
webui4frpc/web/src/App.vue
JianFeeeee f4fb964734 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 各节点自愈
2026-09-18 11:00:35 +08:00

516 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
SPA shell — sakura × frost glassmorphism (ModelRouter-style):
a fixed animated gradient-mesh background, a 246px glass sidebar, and a
content area with a breadcrumb header. Navigation is simple view-state
switching (no router); views own their own padding/scrolling below the
breadcrumb.
-->
<template>
<div id="bgfx" aria-hidden="true">
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
</div>
<LoginView v-if="authReady && !isAuthed" />
<div v-else-if="authReady" class="app-shell">
<aside class="sidebar">
<div class="sb-brand">
<img class="sb-logo" src="/favicon.svg" alt="webui4frpc" />
<span class="sb-brand-text">
<b>webui4frpc</b>
<small>令牌环 · 转发编排</small>
</span>
</div>
<nav class="sb-nav">
<button
v-for="item in nav"
:key="item.key"
class="sb-i"
:class="{ active: view === item.key }"
@click="view = item.key"
>
<span class="ic" v-html="item.icon" />
<span class="label">{{ item.label }}</span>
</button>
</nav>
<div class="sb-foot">
<div class="sb-identity" v-if="authReady">
<span class="id-name">{{ authName || '匿名' }}</span>
<span class="id-level" :class="authLevel">{{ levelLabel }}</span>
<button class="sb-logout" title="退出登录" @click="onLogout">退出</button>
</div>
<div class="sb-theme" role="group" aria-label="主题">
<button class="sw white" :class="{ on: theme === 'white' }" title="白色" @click="setTheme('white')" />
<button class="sw blue" :class="{ on: theme === 'blue' }" title="蓝色" @click="setTheme('blue')" />
</div>
</div>
</aside>
<div class="main">
<div class="breadcrumb">
<span class="crumb">{{ currentLabel }}</span>
</div>
<main class="content">
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<ClusterView v-else-if="view === 'cluster'" />
<UsersView v-else-if="view === 'users'" />
<StatusView v-else />
</main>
</div>
</div>
<div v-else class="boot">加载中…</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import CanvasView from './views/CanvasView.vue'
import SettingsView from './views/SettingsView.vue'
import StatusView from './views/StatusView.vue'
import ClusterView from './views/ClusterView.vue'
import UsersView from './views/UsersView.vue'
import LoginView from './views/LoginView.vue'
import { authLevel, authName, authReady, canWrite, fetchMe, isAuthed, isSuperAdmin, logout } from './auth'
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster' | 'users'
const view = ref<ViewKey>('status')
onMounted(() => { fetchMe() })
// logout clears the session cookie and drops back to the login page.
async function onLogout() {
await logout()
view.value = 'status'
}
const levelLabel = computed(() => {
switch (authLevel.value) {
case 'superadmin': return '超级管理员'
case 'admin': return '管理员'
case 'write': return '写'
default: return '只读'
}
})
// ---- theme switcher (white default / blue) ----
// The data-theme attribute is applied pre-mount by main.ts (no flash); here we
// only mirror it so the active swatch highlights, and update it on click.
type Theme = 'white' | 'blue'
const VALID_THEMES: Theme[] = ['white', 'blue']
const theme = ref<Theme>(
(VALID_THEMES as string[]).includes(document.documentElement.getAttribute('data-theme') || '')
? (document.documentElement.getAttribute('data-theme') as Theme)
: 'white',
)
const setTheme = (t: Theme) => {
theme.value = t
document.documentElement.setAttribute('data-theme', t)
localStorage.setItem('w4f-theme', t)
}
const allNav: { key: ViewKey; label: string; icon: string; writeOnly?: boolean; superAdminOnly?: boolean }[] = [
{ key: 'status', label: '状态', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>' },
{ key: 'canvas', label: '连接配置', writeOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
{ key: 'settings', label: '设置', writeOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
{ key: 'cluster', label: '集群', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4m0 0-5 6m5-6 5 6"/></svg>' },
{ key: 'users', label: '账号与密钥', superAdminOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
]
// nav is filtered by role:
// - canvas/settings need write (viewers/auditors never see them),
// - users needs superadmin (ordinary admins cannot manage accounts),
// - status + cluster are visible to everyone (auditors view + export logs).
const nav = computed(() =>
allNav.filter((n) => {
if (n.superAdminOnly) return authReady.value && isSuperAdmin.value
if (n.writeOnly) return canWrite.value
return true
}),
)
// When the account level drops a page (e.g. a viewer logs in while the canvas
// was open), fall back to the first visible page so we never render a hidden one.
watch(nav, (items) => {
if (!items.some((n) => n.key === view.value)) view.value = 'status'
})
const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?.label ?? '')
</script>
<style scoped>
.app-shell {
height: 100%;
display: flex;
font-family: var(--w4f-font);
color: var(--w4f-fg);
}
/* ---------- sidebar ---------- */
.sidebar {
flex: 0 0 246px;
width: 246px;
padding: 18px 14px 12px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
background: var(--w4f-card);
border-right: 1px solid var(--w4f-line);
backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.7)) saturate(1.3);
box-shadow: var(--w4f-sh-md);
z-index: 30;
}
@media (prefers-reduced-motion: reduce) {
.sidebar { backdrop-filter: none; -webkit-backdrop-filter: none; }
}
.sb-brand {
display: flex;
align-items: center;
gap: 11px;
padding: 6px 8px 18px;
}
.sb-logo {
width: 40px;
height: 40px;
border-radius: 13px;
flex: 0 0 auto;
object-fit: contain;
box-shadow: 0 6px 18px var(--w4f-glow);
}
.sb-brand-text b {
font-size: 16.5px;
letter-spacing: 0.2px;
display: block;
line-height: 1.1;
}
.sb-brand-text small {
display: block;
font-size: 11px;
color: var(--w4f-muted);
margin-top: 3px;
}
.sb-nav {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
}
.sb-i {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
text-align: left;
padding: 10px 13px;
border-radius: 13px;
cursor: pointer;
color: var(--w4f-muted);
font-weight: 600;
font-size: 13.5px;
font-family: inherit;
background: transparent;
border: 1px solid transparent;
transition: background 0.18s var(--w4f-ease-spring), color 0.18s var(--w4f-ease-spring),
border-color 0.18s var(--w4f-ease-spring), box-shadow 0.18s var(--w4f-ease-spring);
}
.sb-i .ic {
display: grid;
place-items: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
opacity: 0.85;
}
.sb-i:hover {
background: var(--w4f-card-2);
color: var(--w4f-fg);
}
.sb-i:hover .ic { opacity: 1; }
.sb-i.active {
background: linear-gradient(120deg, var(--w4f-primary-50), var(--w4f-secondary-50));
color: var(--w4f-primary-h);
border-color: var(--w4f-line);
box-shadow: var(--w4f-sh-sm);
}
.sb-i.active .ic { opacity: 1; }
.sb-foot {
margin-top: auto;
padding-top: 16px;
}
/* ---------- identity chip ---------- */
.sb-identity {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
padding: 8px 10px;
margin-bottom: 12px;
border-radius: 10px;
background: var(--w4f-card-2);
border: 1px solid var(--w4f-line);
font-size: 12.5px;
}
.sb-identity .id-name {
margin-right: auto;
font-weight: 600;
color: var(--w4f-fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-identity .id-level {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
color: var(--w4f-muted);
background: var(--w4f-card);
}
.sb-identity .id-level.admin {
color: #fff;
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
}
.sb-identity .id-level.superadmin {
color: #fff;
background: linear-gradient(135deg, var(--w4f-secondary), var(--w4f-primary));
}
.sb-identity .id-level.write {
color: var(--w4f-warning);
background: var(--w4f-warning-50);
}
.sb-identity .id-level.read {
color: var(--w4f-muted);
}
.sb-logout {
flex: 0 0 auto;
padding: 3px 9px;
border-radius: 7px;
border: 1px solid var(--w4f-line-strong);
background: transparent;
color: var(--w4f-muted);
font-size: 11px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: color 0.15s var(--w4f-ease), border-color 0.15s var(--w4f-ease),
background 0.15s var(--w4f-ease);
}
.sb-logout:hover {
color: var(--w4f-danger);
border-color: var(--w4f-danger);
background: var(--w4f-danger-50);
}
/* ---------- boot splash (shown while /me resolves) ---------- */
.boot {
position: fixed;
inset: 0;
display: grid;
place-items: center;
color: var(--w4f-muted);
font-family: var(--w4f-font);
font-size: 13px;
}
/* ---------- theme switcher ---------- */
.sb-theme { display: flex; gap: 6px; margin-bottom: 12px; }
.sb-theme .sw {
width: 26px; height: 26px; border-radius: 8px; border: 2px solid var(--w4f-line);
cursor: pointer; padding: 0;
transition: transform 0.15s var(--w4f-ease-spring), box-shadow 0.15s var(--w4f-ease-spring);
}
.sb-theme .sw:hover { transform: translateY(-1px); }
.sb-theme .sw.on { border-color: var(--w4f-primary); box-shadow: 0 0 0 3px var(--w4f-glow-soft); }
/* swatch fills are FIXED (represent each theme's identity), not theme-driven */
.sb-theme .sw.white { background: linear-gradient(135deg, #ffffff, #cdd4e0); }
.sb-theme .sw.blue { background: linear-gradient(135deg, #3b82f6, #06b6d4); }
/* ---------- main ---------- */
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px 6px;
font-weight: 700;
font-size: 15px;
}
.breadcrumb .crumb {
opacity: 1;
color: var(--w4f-fg);
}
.content {
flex: 1;
overflow-y: auto;
padding: 8px 24px 40px;
}
@media (max-width: 900px) {
.sidebar { flex: 0 0 210px; width: 210px; }
.breadcrumb { padding: 12px 16px 6px; }
.content { padding: 8px 14px 32px; }
}
/* ---------- narrow screens: sidebar becomes a bottom tab bar ----------
窄屏(≤ 720px)下 210px 侧栅会吃掉手机一半宽度,改为:
- shell 竖向排列,侧栅沉到底部变成固定 tab bar(图标在上文字在下)
- 品牌区/身份区/主题切换折叠进顶部窄条,不占垂直空间
- 内容区预留底部安全区 + tab bar 高度,避开 iOS 手势条
- 每个 tab 至少 44px 高(触控目标下限) */
@media (max-width: 720px) {
.app-shell {
flex-direction: column;
}
/* 顶部窄条:logo + 身份 + 主题,横向排一行 */
.sidebar {
flex: 0 0 auto;
width: 100%;
order: -1;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 10px 12px;
overflow: hidden;
/* 兵家必争:任何子项算错都不得弄出横向滚动条 */
overflow-x: hidden;
border-right: none;
border-bottom: 1px solid var(--w4f-line);
padding-top: max(10px, env(safe-area-inset-top));
/* 关键:backdrop-filter 会为 position:fixed 后代创建【包含块】
(与 transform/filter 同理)。若保留它,下面 .sb-nav 的
bottom:0 就是相对这条顶栏定位而非视口,底部 tab bar 会贴在
顶栏下沿、看起来固定在屏幕顶部。因此窄屏必须关掉顶栏的玻璃
模糊,改用接近实色的底,模糊效果留在 .sb-nav 自己身上
(元素自身的 backdrop-filter 不影响它自己的定位)。 */
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: color-mix(in srgb, var(--w4f-card-solid) 92%, transparent);
}
.sb-brand {
padding: 0;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
/* 品牌文字必须可收缩:“webui4frpc”是不可断行单词,不给 min-width:0
+ overflow:hidden 的话它的 flex 自动最小尺寸等于 min-content(≨85px),
顶栏就拿不出空间给右侧身份区,溢出为横向滚动。 */
.sb-brand-text { min-width: 0; overflow: hidden; }
.sb-brand-text b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-logo { width: 30px; height: 30px; border-radius: 10px; }
.sb-brand-text b { font-size: 14.5px; }
.sb-brand-text small { display: none; }
/* 导航从侧栅里抽出来,固定到底部 */
.sb-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
flex: none;
flex-direction: row;
gap: 0;
padding: 4px 4px max(4px, env(safe-area-inset-bottom));
background: var(--w4f-card);
border-top: 1px solid var(--w4f-line);
backdrop-filter: blur(calc(var(--w4f-glass) * 0.9)) saturate(1.3);
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.9)) saturate(1.3);
box-shadow: 0 -4px 20px rgb(0 0 0 / 6%);
}
.sb-i {
flex: 1 1 0;
min-width: 0;
min-height: 44px;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 5px 2px;
border-radius: 11px;
/* 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;
}
.sb-i .label {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-i.active {
/* 底部 tab 不用渐变底,只高亮图标与文字(避免拥挤) */
background: var(--w4f-primary-50);
border-color: transparent;
box-shadow: none;
}
/* 身份与主题折进顶条右侧。
不再用 vw 限宽:vw 含垂直滚动条宽度,会比实际可用内容宽大;而且
max-width 只封顶盒子,内部 flex:0 0 auto 的徒章/按钮仍会撑出去。
改为全链路可收缩(min-width:0 + overflow:hidden),由 flex 自行分配。 */
.sb-foot {
margin-top: 0;
padding-top: 0;
display: flex;
align-items: center;
gap: 8px;
flex: 0 1 auto;
min-width: 0;
}
.sb-identity {
margin-bottom: 0;
padding: 5px 8px;
font-size: 11.5px;
flex: 0 1 auto;
min-width: 0;
max-width: none;
overflow: hidden;
}
.sb-identity .id-name { min-width: 0; max-width: none; }
.sb-theme { margin-bottom: 0; flex: 0 0 auto; }
.sb-theme .sw { width: 22px; height: 22px; border-radius: 7px; }
.main { height: auto; flex: 1; min-height: 0; }
.breadcrumb { padding: 10px 14px 4px; font-size: 14px; }
.content {
padding: 6px 12px 24px;
/* 底部 tab bar(44px + padding)+ 安全区 */
padding-bottom: calc(64px + env(safe-area-inset-bottom));
}
}
/* 极窄(≤ 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; }
.content { padding-left: 10px; padding-right: 10px; }
}
</style>