mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-03 15:43:59 +00:00
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:
270
web/src/App.vue
270
web/src/App.vue
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user