Files
webui4frpc/web/src/views/StatusView.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

742 lines
29 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<template>
<div class="status-page">
<!-- top bar -->
<div class="topbar w4f-card">
<div class="tb-left">
<h2 class="page-title">状态总览</h2>
<span class="page-sub">每 2.5 秒自动刷新</span>
</div>
<div class="tb-right">
<button class="w4f-btn ghost small" @click="loadStatus">⟳ 刷新</button>
<button v-if="canWrite" class="w4f-btn small" @click="openAdd">+ 添加远程节点</button>
</div>
</div>
<!-- KPI grid -->
<div class="kpis" v-if="status">
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: !allConnected }" />远程节点</span><span class="k-ic">⊕︎</span></div>
<div class="k-val">{{ status.profiles.length }}</div>
<div class="k-sub">在线 {{ onlineCount }} · 离线 {{ status.profiles.length - onlineCount }}</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ err: runningCount === 0 && status.profiles.length > 0 }" />worker 进程</span><span class="k-ic">⚙︎</span></div>
<div class="k-val">{{ runningCount }}</div>
<div class="k-sub">运行中 frpc 进程数</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" />本地服务</span><span class="k-ic">◳</span></div>
<div class="k-val">{{ status.localStatus.length }}</div>
<div class="k-sub">已配置本地服务</div>
</div>
<div class="w4f-kpi">
<div class="k-hdr"><span class="k-lab"><span class="w4f-dot" :class="{ pending: activeForwards > 0 }" />活跃转发</span><span class="k-ic">→</span></div>
<div class="k-val">{{ activeForwards }}</div>
<div class="k-sub">已编排转发链路</div>
</div>
</div>
<div class="status-body" v-if="status">
<!-- 转发 (forward-centric): 本地/远程 by localOnly, per-card 启停, 分组一键 -->
<section v-for="g in forwardsByGroup" :key="g.key || '__none__'" class="section">
<h3 class="section-title">
<span class="k-ic">→</span>{{ g.label }}
<span class="grp-count">{{ g.items.length }} 条</span>
<span class="grp-actions">
<button v-if="canWrite" class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
<button v-if="canWrite" class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
<button v-if="canWrite && g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
</span>
</h3>
<div class="card-grid">
<div
v-for="f in g.items"
:key="f.local + ':' + f.remote + ':' + f.remotePort"
class="fwd-card w4f-card"
:class="{ active: f.active, stopped: f.disabled }"
>
<div class="fwd-head">
<span class="fwd-kind" :class="f.kind">{{ f.kind === 'local' ? '本地' : '远程' }}</span>
<span
class="fwd-dot"
:class="{ on: f.active }"
:title="f.active ? '运行中' : f.disabled ? '已停止' : '未运行'"
/>
<span class="fwd-route">
<b>{{ f.local }}</b><span class="fwd-ip" v-if="f.localIp"> {{ f.localIp }}:{{ f.localPort }}</span>
<span class="fwd-arrow">→</span>
<b>{{ f.remote }}</b>:<span class="fwd-port">{{ f.remotePort }}</span>
</span>
<span v-if="f.localProto" class="w4f-tag w4f-tag-primary">{{ f.localProto }}</span>
<span
class="w4f-tag w4f-tag-info group-chip"
:class="{ readonly: !canWrite }"
@click="canWrite && editGroup(f)"
:title="canWrite ? '点击修改分组(当前:' + (f.group || '未分组') + ')' : '当前分组:' + (f.group || '未分组')"
>{{ f.group || '未分组' }}</span>
<span v-if="f.kind === 'remote'" class="fwd-owner" :title="f.ownerId || ''">{{ ownerLabel(f) }}</span>
<span class="fwd-actions">
<button v-if="canWrite && !f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
<button v-else-if="canWrite" class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
</span>
</div>
</div>
<div v-if="!g.items.length" class="empty">该组暂无转发</div>
</div>
</section>
<div v-if="!status.forwards.length" class="empty">尚未配置转发 — 前往「连接配置」画布编排 local→remote 连线</div>
<!-- 远程 frps 节点 (只读连接态; 启停已移至转发卡) -->
<section class="section">
<h3 class="section-title"><span class="k-ic">⊕</span>远程节点状态</h3>
<div class="card-grid">
<div
v-for="p in status.profiles"
:key="p.name"
class="node-card w4f-card"
:class="{ healthy: workerHealthy(p.process.state) }"
>
<div class="nc-head">
<span class="nc-name">{{ p.name }}</span>
<span class="w4f-tag" :class="connTagClass(p.connState)">
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
</span>
<span class="nc-actions">
<button v-if="canWrite" class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
<button v-if="canWrite" class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
</span>
</div>
<div class="nc-meta">
<span v-if="p.process.pid" class="meta-pill">pid {{ p.process.pid }}</span>
<span v-if="p.process.restartCount" class="meta-pill warn">重启 {{ p.process.restartCount }}</span>
<span v-if="p.process.err" class="meta-pill err" :title="p.process.err">{{ p.process.err }}</span>
<span v-if="remoteVhostPort(p.name)" class="meta-pill info">HTTP :{{ remoteVhostPort(p.name) }}</span>
</div>
<div class="nc-forwards" v-if="p.forwards.length">
<span v-for="f in p.forwards" :key="f.service" class="w4f-tag w4f-tag-primary">
{{ f.service }} → :{{ f.remotePort }}
</span>
</div>
<div v-if="p.groupCount" class="nc-groups">
<span class="w4f-tag w4f-tag-info">LB ×{{ p.groupCount }}</span>
</div>
<div v-if="p.proxyStates?.length" class="nc-proxies">
<div
v-for="ps in p.proxyStates"
:key="ps.name"
class="nc-proxy"
:class="proxyStateClass(ps.status)"
>
<span class="np-name">{{ ps.name }}</span>
<span class="np-status">{{ proxyStateLabel(ps.status) }}</span>
<div class="w4f-bar" :class="proxyBarClass(ps.status)"><i :style="{ width: proxyBarWidth(ps.status) }" /></div>
<span v-if="ps.remote_addr" class="np-addr">{{ ps.remote_addr }}</span>
<span v-if="ps.err" class="np-err" :title="ps.err">{{ ps.err }}</span>
</div>
</div>
<div v-else-if="p.adminEnabled" class="nc-proxies-empty">
管理 API 未返回状态(frpc 未连上远程 frps 或无代理)
</div>
</div>
<div v-if="!status.profiles.length" class="empty">尚未配置远程节点 — 点击右上「+ 添加远程节点」</div>
</div>
</section>
<!-- 本地服务 (overview) -->
<section class="section">
<h3 class="section-title"><span class="k-ic">◳</span>本地服务</h3>
<div class="card-grid">
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-card w4f-card">
<div class="lc-head">
<span class="lc-name">{{ ls.local.name }}</span>
<span class="lc-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="w4f-tag w4f-tag-primary">{{ ls.local.protocol }}</span>
<span v-if="ls.local.localOnly" class="w4f-tag w4f-tag-ok">本地</span>
</div>
<div class="lc-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="lc-target">
<span class="lc-arrow">→</span>
<span class="lc-remote">{{ t.remote }}</span>
<span class="lc-port">:{{ t.remotePort }}</span>
<span class="w4f-tag" :class="workerHealthy(t.workerState) ? 'w4f-tag-ok' : 'w4f-tag-danger'">
{{ localWorkerLabel(t.workerState) }}
</span>
</div>
<div v-if="!ls.targets.length" class="lc-none">未转发</div>
</div>
</div>
<div v-if="!status.localStatus.length" class="empty">尚未配置本地服务 — 前往「连接配置」画布编排</div>
</div>
</section>
</div>
<el-dialog v-model="dialog.visible" :title="dialog.isNew ? '添加远程节点' : '编辑远程节点'" width="440px">
<div class="form-row">
<label>名称</label>
<el-input v-model="dialog.remote.name" :disabled="!canWrite || !dialog.isNew" placeholder="如 aliyun-hz" />
</div>
<div class="form-row">
<label>服务器 IP</label>
<el-input v-model="dialog.remote.ip" :disabled="!canWrite" placeholder="frps 地址" />
</div>
<div class="form-row">
<label>端口</label>
<el-input-number v-model="dialog.remote.port" :disabled="!canWrite" :min="1" :max="65535" />
</div>
<div class="form-row">
<label>Token</label>
<el-input v-model="dialog.remote.token" :disabled="!canWrite" placeholder="可选" />
</div>
<div class="form-row">
<label>URL</label>
<el-input v-model="dialog.remote.url" :disabled="!canWrite" placeholder="可选" />
</div>
<div class="form-row">
<label>启用</label>
<el-switch v-model="dialog.remote.enabled" :disabled="!canWrite" />
</div>
<template #footer>
<button class="w4f-btn ghost small" @click="dialog.visible = false">取消</button>
<button class="w4f-btn small" :disabled="!canWrite" @click="saveRemote">保存</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api'
import { canWrite } from '../auth'
import type { ForwardStatus, Remote, StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
let timer: number | null = null
let visHandler: (() => void) | null = null
interface RemoteDialog {
visible: boolean
isNew: boolean
remote: Remote
}
const newRemote = (): Remote => ({
name: '',
ip: '',
port: 7000,
token: '',
url: '',
enabled: true,
})
const dialog = reactive<RemoteDialog>({
visible: false,
isNew: true,
remote: newRemote(),
})
const openAdd = () => {
dialog.isNew = true
dialog.remote = newRemote()
dialog.visible = true
}
const openEdit = (p: { name: string }) => {
const found = status.value?.remotes.find((r) => r.name === p.name)
dialog.isNew = false
dialog.remote = found
? { ...found }
: { name: p.name, ip: '', port: 7000, token: '', url: '', enabled: true }
dialog.visible = true
}
const saveRemote = async () => {
const d = dialog.remote
if (!d.name || !d.ip || d.port <= 0) {
ElMessage.warning('请填写名称、IP、端口')
return
}
try {
await api.saveRemote(d)
dialog.visible = false
ElMessage.success(dialog.isNew ? '节点已添加' : '节点已更新')
await loadStatus()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.message || e))
}
}
// ---- Per-forward start/stop (forwards page) ----
// local-only → toggle local frpc worker (render skips disabled proxies);
// cluster → ring submit/revoke. Idempotent on the backend.
const startForward = async (f: ForwardStatus) => {
try {
await api.forwardStart(f.local, f.remote, f.remotePort)
await loadStatus()
} catch (e: any) {
ElMessage.error('启动转发失败: ' + (e.message || e))
}
}
const stopForward = async (f: ForwardStatus) => {
try {
await api.forwardStop(f.local, f.remote, f.remotePort)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止转发失败: ' + (e.message || e))
}
}
const groupStart = async (group: string) => {
try {
await api.groupStart(group)
await loadStatus()
} catch (e: any) {
ElMessage.error('启动分组失败: ' + (e.message || e))
}
}
const groupStop = async (group: string) => {
try {
await api.groupStop(group)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止分组失败: ' + (e.message || e))
}
}
// editGroup opens a prompt to change a single forward's group label from the
// card's group chip. Lists existing group names for quick selection; empty
// input moves the forward to 未分组. Pure DB update — no worker/ring action.
const editGroup = async (f: ForwardStatus) => {
const existing = new Set<string>()
for (const fw of status.value?.forwards ?? []) {
if (fw.group) existing.add(fw.group)
}
const hint = existing.size
? `现有分组:${[...existing].join('、')}(留空=移出分组)`
: '输入分组名称(留空=移出分组)'
try {
const { value } = await ElMessageBox.prompt(hint, '修改分组', {
inputValue: f.group || '',
inputPlaceholder: '分组名称',
confirmButtonText: '确定',
cancelButtonText: '取消',
})
const g = (value || '').trim()
await api.assignGroup(f.local, f.remote, f.remotePort, g)
await loadStatus()
} catch (e: any) {
// user cancelled (reject) → no action; real errors show a toast
if (e !== 'cancel' && e?.message) {
ElMessage.error('修改分组失败: ' + (e.message || e))
}
}
}
// deleteGroup dissolves a named group: all members are moved to 未分组. The
// group itself is just a label on links, so clearing all members is the
// complete delete. Confirms first since it affects every forward in the group.
const deleteGroup = async (group: string) => {
if (!window.confirm(`删除分组「${group}」?组内所有转发将移至未分组(转发本身不受影响)。`)) return
try {
await api.deleteGroup(group)
await loadStatus()
ElMessage.success(`已删除分组「${group}」`)
} catch (e: any) {
ElMessage.error('删除分组失败: ' + (e.message || e))
}
}
// ownerLabel: human-readable topology owner for a remote (cluster) forward.
const ownerLabel = (f: ForwardStatus) => {
if (f.kind !== 'remote') return ''
if (!f.ownerId) return '待分配'
if (f.ownerId === status.value?.selfId) return '本机持有'
return '持有 ' + f.ownerId
}
const removeRemote = async (name: string) => {
if (!window.confirm(`删除远程节点 ${name}?`)) return
try {
await api.deleteRemote(name)
await loadStatus()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e.message || e))
}
}
const loadStatus = async () => {
try {
status.value = await api.status()
} catch {
// keep last known status on transient errors
}
}
// ---- KPI derivations ----
const workerHealthy = (s?: string) => s === 'running'
const runningCount = computed(() => status.value?.profiles.filter((p) => p.process.state === 'running').length ?? 0)
const onlineCount = computed(() => status.value?.profiles.filter((p) => p.connState === 'connected').length ?? 0)
const allConnected = computed(() => (status.value?.profiles.length ?? 0) > 0 && onlineCount.value === status.value?.profiles.length)
const activeForwards = computed(() => status.value?.forwards.filter((f) => f.active).length ?? 0)
// forwardsByGroup buckets the forward-centric list by Link.group; the empty
// group is labelled 未分组 and sorted last so named groups surface first.
const forwardsByGroup = computed(() => {
const map = new Map<string, ForwardStatus[]>()
for (const f of status.value?.forwards ?? []) {
const key = f.group || ''
let arr = map.get(key)
if (!arr) {
arr = []
map.set(key, arr)
}
arr.push(f)
}
return [...map.entries()]
.map(([key, items]) => ({ key, label: key || '未分组', items }))
.sort((a, b) => {
if (!a.key) return 1
if (!b.key) return -1
return a.label.localeCompare(b.label, 'zh')
})
})
// connState refers to the frps connection from the local frpc worker.
// We never control the remote frps itself.
const connStateLabel = (s?: string) => {
switch (s) {
case 'connected': return '已连接'
case 'connecting': return '连接中'
case 'failed': return '连接失败'
case 'disabled': return '已停用'
case 'not_started':
default: return '未启动'
}
}
const connTagClass = (s?: string) => {
switch (s) {
case 'connected': return 'w4f-tag-ok'
case 'connecting': return 'w4f-tag-info'
case 'failed': return 'w4f-tag-danger'
case 'disabled':
case 'not_started':
default: return 'w4f-tag-warn'
}
}
const connDotClass = (s?: string) => {
switch (s) {
case 'connected': return ''
case 'connecting': return 'pending'
case 'failed': return 'err'
default: return ''
}
}
// M3: per-proxy state from frpc admin API.
const proxyStateLabel = (s?: string) => {
switch (s) {
case 'running': return '运行中'
case 'wait start': return '等待启动'
case 'start error': return '启动错误'
case 'check failed': return '健康检查失败'
case 'closed': return '已关闭'
case 'new':
default: return '新建'
}
}
const proxyStateClass = (s?: string) => {
switch (s) {
case 'running': return 'ok'
case 'check failed':
case 'start error': return 'err'
case 'wait start': return 'pending'
default: return ''
}
}
const proxyBarClass = (s?: string) => (s === 'running' ? 'ok' : s === 'check failed' || s === 'start error' ? 'err' : '')
const proxyBarWidth = (s?: string) => (s === 'running' ? '100%' : s === 'wait start' ? '45%' : s === 'new' ? '15%' : '30%')
// localWorkerLabel: state of the LOCAL frpc worker driving this target
// (we do not control the remote frps).
const localWorkerLabel = (s?: string) => {
switch (s) {
case 'running': return '运行中'
case 'starting': return '启动中'
case 'restarting': return '重启中'
case 'crashed': return '异常'
default: return '未启动'
}
}
// remoteVhostPort returns the frps vhostHTTPPort for a remote (0 if unset).
const remoteVhostPort = (name: string): number =>
status.value?.remotes.find((r) => r.name === name)?.vhostHttpPort || 0
onMounted(() => {
loadStatus()
// 2.5s cadence keeps the status page live against cluster dispatch +
// worker state changes; visibilitychange reloads instantly on tab refocus
// so a stale tab never lingers on pre-action data.
timer = window.setInterval(loadStatus, 2500)
visHandler = () => {
if (!document.hidden) loadStatus()
}
document.addEventListener('visibilitychange', visHandler)
})
onBeforeUnmount(() => {
if (timer !== null) {
window.clearInterval(timer)
timer = null
}
if (visHandler) {
document.removeEventListener('visibilitychange', visHandler)
visHandler = null
}
})
</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 {
display: flex;
flex-direction: column;
gap: 14px;
}
/* top bar */
.topbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
}
.tb-left { display: flex; flex-direction: column; gap: 3px; }
.page-title { margin: 0; font-size: 17px; font-weight: 800; }
.page-sub { font-size: 12px; color: $color-text-muted; }
.tb-right { display: flex; gap: 8px; }
/* KPI grid */
.kpis {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
gap: 14px;
}
.status-body {
display: flex;
flex-direction: column;
gap: 18px;
}
.section { display: flex; flex-direction: column; }
.section-title {
margin: 0 0 10px;
font-size: 13.5px;
font-weight: 700;
color: $color-text-muted;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 10px;
.k-ic { width: 26px; height: 26px; border-radius: 9px; display: grid; place-items: center; font-size: 13px;
background: linear-gradient(135deg, var(--w4f-primary-50), var(--w4f-secondary-50)); color: var(--w4f-primary-h); }
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
/* remote node card */
.node-card {
padding: 16px 18px;
border-left: 4px solid var(--w4f-danger);
&.healthy { border-left-color: var(--w4f-ok); }
}
.nc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.nc-name { font-weight: 800; font-size: 15px; margin-right: auto; }
.nc-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.nc-cluster-note { font-size: 11.5px; color: $color-text-muted; padding: 3px 9px; border-radius: $radius-pill;
background: var(--w4f-card-2); cursor: help; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.meta-pill { font-size: 11.5px; padding: 2px 9px; border-radius: $radius-pill; background: var(--w4f-card-2); color: $color-text-muted;
font-family: var(--w4f-mono); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
&.warn { background: $color-warning-50; color: $color-warning; }
&.err { background: $color-danger-50; color: $color-danger; }
&.info { background: $color-secondary-50; color: $color-secondary-h; }
}
.nc-forwards { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.nc-groups { margin-top: 6px; }
.nc-proxies { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.nc-proxy { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 10px; border-radius: $radius-xs;
background: var(--w4f-card-2); border: 1px solid var(--w4f-line);
&.ok { border-left: 3px solid $color-success; }
&.pending { border-left: 3px solid $color-secondary; }
&.err { border-left: 3px solid $color-danger; }
.w4f-bar { width: 60px; }
}
.np-name { font-weight: 700; }
.np-status { font-size: 11px; color: $color-text-secondary; }
.np-addr { margin-left: auto; font-size: 11px; color: $color-text-muted; font-family: var(--w4f-mono); }
.np-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nc-proxies-empty { margin-top: 8px; font-size: 12px; color: $color-text-muted; }
/* forward card (转发为单元) */
.fwd-card {
padding: 12px 14px;
border-left: 4px solid var(--w4f-muted);
transition: border-color 0.18s var(--w4f-ease-spring);
&.active { border-left-color: var(--w4f-ok); }
&.stopped { border-left-color: var(--w4f-danger); opacity: 0.78; }
}
.fwd-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.fwd-kind {
font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: $radius-pill;
color: var(--w4f-muted); background: var(--w4f-card-2);
&.local { color: var(--w4f-ok-h, #fff); background: var(--w4f-ok-50, var(--w4f-primary-50)); }
&.remote { color: var(--w4f-secondary-h); background: var(--w4f-secondary-50); }
}
.fwd-dot {
width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto;
background: var(--w4f-muted); box-shadow: 0 0 0 3px var(--w4f-card-2);
&.on { background: var(--w4f-ok); box-shadow: 0 0 0 3px var(--w4f-ok-50, var(--w4f-primary-50)); }
}
.fwd-route { font-size: 13.5px; display: flex; align-items: center; gap: 5px; margin-right: auto; }
.fwd-ip { font-size: 11.5px; color: $color-text-muted; font-family: var(--w4f-mono); }
.fwd-arrow { color: var(--w4f-primary); font-weight: 800; }
.fwd-port { font-family: var(--w4f-mono); color: $color-text-secondary; }
.fwd-owner { font-size: 11.5px; color: $color-text-muted; padding: 2px 8px; border-radius: $radius-pill;
background: var(--w4f-card-2); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fwd-actions { display: flex; gap: 6px; }
.grp-count { font-size: 12px; color: $color-text-muted; font-weight: 600; }
.grp-actions { margin-left: auto; display: flex; gap: 6px; }
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } &.readonly { cursor: default; &:hover { background: none; } } }
/* local service card */
.local-card { padding: 14px 16px; }
.lc-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lc-name { font-weight: 800; font-size: 14px; }
.lc-addr { font-size: 12px; color: $color-text-secondary; font-family: var(--w4f-mono); }
.lc-targets { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.lc-target { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.lc-arrow { color: var(--w4f-primary); font-weight: 700; }
.lc-remote { font-weight: 600; }
.lc-port { color: $color-text-secondary; font-family: var(--w4f-mono); margin-right: auto; }
.lc-none { font-size: 12px; color: $color-text-light; }
.empty { grid-column: 1 / -1; color: $color-text-muted; font-size: 13px; padding: 18px; text-align: center; }
/* dialog form */
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; }
}
/* ---------- narrow screens ----------
窄屏下的主要问题:
- card-grid 的 minmax(320px) 在 375px 屏上会溢出(加上内边距超宽)
- topbar 的 space-between 把标题和按钮挤在一行
- fwd-head 一行塞下 7~8 个元素(类型/点/路由/协议/分组/归属/按钮)
- 分组标题的三个按钮 margin-left:auto 后被挤成细条
- 触控目标(small 按钮)低于 44px */
@media (max-width: 720px) {
.status-page { gap: 10px; }
/* 标题与工具栏换行,按钮占整行平分 */
.topbar {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 12px 14px;
}
.tb-right { width: 100%; }
.tb-right > .w4f-btn { flex: 1 1 0; min-height: 40px; }
/* KPI 固定两列(auto-fit 在窄屏会变单列,四张卡拉得太长) */
.kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
/* 卡片网格改单列:消除 320px 最小宽导致的横向溢出 */
.card-grid {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
/* 分组标题:标题一行,操作按钮另起一行平分 */
.section-title {
flex-wrap: wrap;
row-gap: 8px;
}
.grp-actions {
margin-left: 0;
width: 100%;
gap: 6px;
}
.grp-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; font-size: 12px; }
/* 转发卡:路由占整行,其余属性换行,按钮另起一行 */
.fwd-card { padding: 11px 12px; }
.fwd-head { row-gap: 7px; }
.fwd-route {
order: -1;
width: 100%;
margin-right: 0;
flex-wrap: wrap;
row-gap: 2px;
font-size: 13px;
}
.fwd-owner { max-width: 100%; }
.fwd-actions {
width: 100%;
margin-top: 2px;
}
.fwd-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; }
/* 远程节点卡:名称占行,操作按钮平分 */
.node-card { padding: 14px 15px; }
.nc-name { width: 100%; margin-right: 0; }
.nc-actions { width: 100%; }
.nc-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; }
.nc-cluster-note { max-width: 100%; }
.meta-pill { max-width: 100%; }
.np-err { max-width: 45%; }
/* 对话框表单:标签在上、输入在下(窄屏下 72px 标签挤死输入框) */
.form-row {
flex-direction: column;
align-items: stretch;
gap: 5px;
label { width: auto; }
}
}
@media (max-width: 380px) {
/* 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; }
}
</style>