feat: Phase C 完成 — ModelRouter 风格 UI 重设计 + 模拟 frps 测试 + lastSync 修复 + 集群作坊搭建

- 主题: sakura×frost 玻璃拟态 (theme.css) + SCSS 变量重映射
- 侧栏: 玻璃侧栏 246px + 渐变品牌区 + 面包屑导航
- 状态页: 玻璃 KPI 卡 + 远程节点/本地服务卡片网格
- 集群页: 英雄玻璃卡 + 横向环拓扑链 + 待办命令/活跃拓扑/日志区
- 令牌环: 新增 lastSync 上次同步时间替代周期计数
- 模拟 frps: frps2/frps3 容器 + test-forward.sh 全链路验证脚本
- 修复: BinaryPath 空导致 worker 不启动, 撤销仅撤第一个 link, 任务复活风暴 (published 追踪)
This commit is contained in:
2026-08-18 23:38:20 +08:00
parent 86b265637d
commit b518a13446
32 changed files with 2337 additions and 976 deletions

View File

@ -1,100 +1,236 @@
<!-- 简单 SPA 外壳:顶部切换 状态 / 连接配置 / 设置 -->
<!--
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>
<div class="app-shell">
<header class="topbar">
<span class="brand">webui4frpc</span>
<nav class="nav">
<button class="nav-btn" :class="{ active: view === 'status' }" @click="view = 'status'">
状态
</button>
<button class="nav-btn" :class="{ active: view === 'canvas' }" @click="view = 'canvas'">
连接配置
</button>
<button class="nav-btn" :class="{ active: view === 'settings' }" @click="view = 'settings'">
设置
</button>
<button class="nav-btn" :class="{ active: view === 'cluster' }" @click="view = 'cluster'">
集群
<aside class="sidebar">
<div class="sb-brand">
<span class="sb-logo" aria-hidden="true">W</span>
<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>
</header>
<main class="content">
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<ClusterView v-else-if="view === 'cluster'" />
<StatusView v-else />
</main>
<div class="sb-foot">
<span class="sb-status"><span class="dot" /> token-ring · M6</span>
</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'" />
<StatusView v-else />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } 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'
const view = ref<'canvas' | 'settings' | 'status' | 'cluster'>('status')
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster'
const view = ref<ViewKey>('status')
const nav: { key: ViewKey; label: string; icon: string }[] = [
{ 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: '连接配置', 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: '设置', 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>' },
]
const currentLabel = computed(() => nav.find((n) => n.key === view.value)?.label ?? '')
</script>
<style>
html,
body,
#app {
margin: 0;
height: 100%;
}
<style scoped>
.app-shell {
height: 100%;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
background: #f7f8fa;
font-family: var(--w4f-font);
color: var(--w4f-fg);
}
.topbar {
flex-shrink: 0;
/* ---------- 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: 24px;
padding: 0 20px;
height: 52px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
gap: 11px;
padding: 6px 8px 18px;
}
.sb-logo {
width: 40px;
height: 40px;
border-radius: 13px;
flex: 0 0 auto;
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 19px;
letter-spacing: 0.5px;
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
box-shadow: 0 6px 18px rgba(255, 127, 172, 0.45);
}
.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;
}
.brand {
font-weight: 600;
font-size: 16px;
}
.nav {
.sb-nav {
display: flex;
gap: 8px;
flex-direction: column;
gap: 3px;
flex: 1;
}
.nav-btn {
border: none;
background: transparent;
padding: 6px 16px;
border-radius: 8px;
.sb-i {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
text-align: left;
padding: 10px 13px;
border-radius: 13px;
cursor: pointer;
font-size: 14px;
color: #606266;
&:hover {
background: #f2f3f5;
}
&.active {
background: #303133;
color: #fff;
}
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;
}
.sb-status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border-radius: var(--w4f-radius-pill);
font-size: 12.5px;
font-weight: 600;
color: var(--w4f-primary-h);
background: var(--w4f-primary-50);
}
.sb-status .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--w4f-primary);
box-shadow: 0 0 0 3px rgba(255, 127, 172, 0.18);
animation: blink 1.6s infinite;
}
@keyframes blink { 50% { opacity: 0.25; } }
/* ---------- 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;
min-height: 0;
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; }
}
</style>