mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-22 01:47:58 +00:00
- cookie-based auth (/login /logout) replacing Basic Auth for UI, enabling logout - roles: superadmin (account management only), admin (full except accounts), viewer/audit (read-only status+cluster, export logs) - readonly accounts hide edit buttons (added remote node, group ops, canvas layout/save/import, cluster manage, install) instead of greying them - auditors see status+cluster only; ordinary admins lose the accounts nav; last-admin guard covers superadmin - remove pink theme entirely (switcher, [data-theme=pink], leftover localStorage), keep white/blue
367 lines
12 KiB
Vue
367 lines
12 KiB
Vue
<!--
|
||
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; }
|
||
}
|
||
</style>
|