Files
webui4frpc/web/src/App.vue
jianf f29ec81e4a feat: session-cookie login/logout + three-tier roles (superadmin/admin/viewer) with read-only UI + remove pink theme
- 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
2026-08-20 09:08:15 +08:00

367 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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.

<!--
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>