Files
webui4frpc/web/src/views/StatusView.vue
JianFeeeee f935cad33f feat(web): 窄屏适配 — 侧栏转底部 tab bar + 各页响应式重排
此前只有一个 max-width:900px 断点(仅缩窄侧栏),手机上 210px 侧栏吃掉
半屏、卡片网格横向溢出、按钮被挤成细条。新增 720px / 380px 两档断点:

App.vue(关键)
- ≤720px:shell 转竖向,导航从侧栏抽出固定到屏幕底部(图标上/文字下),
  品牌+身份+主题折进顶部窄条;tab 最小 44px 触控高度
- 内容区预留 env(safe-area-inset-bottom),避开 iOS 手势条
- ≤380px 再压一档:隐藏权限徽标、缩小字号

StatusView
- card-grid 的 minmax(320px) 在 375px 屏溢出 → 单列
- KPI 由 auto-fit 改固定两列(auto-fit 窄屏退化单列使四卡拉长),≤380px 单列
- 转发卡 fwd-head 原一行塞 7~8 元素 → 路由占整行、按钮另起一行平分
- 分组标题按钮不再被 margin-left:auto 压扁

ClusterView
- 环拓扑改竖向,节点卡占满宽,箭头旋转 90° 指下
- nodeKey(32 位 hex)允许 break-all,不再撑宽容器
- 日志 lg-detail 由 max-width:60%+nowrap 改整行完整显示

SettingsView / UsersView / CanvasView
- binary-row、setting-row 竖排;表格卡标题行竖排、操作按钮平分
- 画布 toolbar 按钮换行平铺;vue-flow handle 12px→16px(触控可达)

index.html
- viewport 加 viewport-fit=cover,这是 env(safe-area-inset-*) 生效前提

验证:tsc --noEmit 通过、npm run build 通过、dist 已重新 embed,
三节点部署后环正常(pending=0,5 条转发 frpc 进程与 topology 归属一致)。
2026-09-03 07:20:32 +08:00

729 lines
28 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">尚未配置转发 前往连接配置画布编排 localremote 连线</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">
.status-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
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 {
flex: 1;
display: flex;
flex-direction: column;
gap: 18px;
overflow-y: auto;
padding-right: 4px;
}
.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) {
/* 极窄KPI 也降为单列,否则数字会被压断 */
.kpis { grid-template-columns: minmax(0, 1fr); }
.fwd-route { font-size: 12.5px; }
.page-title { font-size: 16px; }
}
</style>