Files
sentinel/frontend/src/App.vue

317 lines
7.1 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.
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { clearAuthToken } from './api'
import { subscribeToAnnouncements } from './utils/liveRegion'
const route = useRoute()
const router = useRouter()
const clockLabel = ref('')
const liveMessage = ref('')
let clockTimer
let clearAnnouncementTimer
let unsubscribeAnnouncements = () => {}
const navItems = [
{ label: '总览看板', name: 'dashboard', icon: 'DataAnalysis' },
{ label: '绑定管理', name: 'bindings', icon: 'Connection' },
{ label: '拦截日志', name: 'logs', icon: 'WarningFilled' },
{ label: '运行设置', name: 'settings', icon: 'Setting' },
]
const hideShell = computed(() => Boolean(route.meta.public))
const currentSection = computed(() => route.meta.kicker || '控制台')
function updateClock() {
clockLabel.value = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date())
}
async function logout() {
clearAuthToken()
await router.push({ name: 'login' })
}
onMounted(() => {
updateClock()
clockTimer = window.setInterval(updateClock, 60000)
unsubscribeAnnouncements = subscribeToAnnouncements((message) => {
liveMessage.value = message
if (clearAnnouncementTimer) {
window.clearTimeout(clearAnnouncementTimer)
}
clearAnnouncementTimer = window.setTimeout(() => {
liveMessage.value = ''
}, 3000)
})
})
onBeforeUnmount(() => {
if (clockTimer) {
window.clearInterval(clockTimer)
}
if (clearAnnouncementTimer) {
window.clearTimeout(clearAnnouncementTimer)
}
unsubscribeAnnouncements()
})
</script>
<template>
<p class="sr-only" aria-live="polite" role="status">{{ liveMessage }}</p>
<router-view v-if="hideShell" />
<div v-else class="shell">
<a class="skip-link" href="#main-content">跳转到主要内容</a>
<div class="shell-glow shell-glow--mint" />
<div class="shell-glow shell-glow--amber" />
<aside class="shell-sidebar panel">
<div class="brand-block">
<div class="brand-mark">S</div>
<div>
<p class="eyebrow">Key-IP Sentinel</p>
<h1 class="brand-title">安全控制台</h1>
<p class="brand-subtitle">API Key 首次使用 IP 绑定网关</p>
</div>
</div>
<nav class="nav-list" aria-label="Primary">
<router-link
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="nav-link"
active-class="is-active"
>
<component :is="item.icon" class="nav-icon" />
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="sidebar-note">
<p class="eyebrow">当前能力</p>
<h3>绑定审计告警一体化</h3>
<p class="muted">
所有请求先经过边界网关首次调用自动绑定来源地址后续按 IP CIDR 持续校验
</p>
</div>
<div class="rail-grid">
<div class="rail-card">
<span class="rail-label">入口</span>
<strong>管理后台</strong>
<span class="rail-meta">JWT 鉴权</span>
</div>
<div class="rail-card">
<span class="rail-label">网关</span>
<strong>流式代理</strong>
<span class="rail-meta">支持 SSE 透传</span>
</div>
</div>
</aside>
<main class="shell-main" aria-labelledby="page-title">
<header class="shell-header panel">
<div class="header-copy">
<p class="eyebrow">{{ currentSection }}</p>
<h2 id="page-title" class="page-title">{{ route.meta.title || 'Sentinel' }}</h2>
<p class="muted header-note">围绕绑定记录拦截日志和运行设置的统一运维入口</p>
</div>
<div class="header-actions">
<div class="header-chip-group">
<div class="header-chip">
<span class="header-chip-label">模式</span>
<strong>安全代理</strong>
</div>
<div class="header-chip" aria-live="polite">
<span class="header-chip-label">时间</span>
<strong>{{ clockLabel }}</strong>
</div>
</div>
<el-button type="primary" plain @click="logout">退出登录</el-button>
</div>
</header>
<section id="main-content" class="shell-content" tabindex="-1">
<router-view />
</section>
</main>
</div>
</template>
<style scoped>
.shell {
position: relative;
display: grid;
grid-template-columns: 276px minmax(0, 1fr);
gap: 18px;
min-height: 100vh;
padding: 18px;
}
.shell-sidebar,
.shell-header {
position: relative;
z-index: 1;
}
.shell-sidebar {
display: flex;
flex-direction: column;
gap: 20px;
padding: 22px;
}
.brand-block {
display: flex;
align-items: center;
gap: 16px;
}
.brand-mark {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, #6ea7ff, #86c8ff);
color: #ffffff;
font-size: 1.2rem;
font-weight: 800;
}
.brand-title,
.page-title {
margin: 0;
font-size: clamp(1.2rem, 1.6vw, 1.6rem);
}
.nav-list {
display: grid;
gap: 10px;
}
.nav-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 16px;
color: var(--sentinel-ink-soft);
text-decoration: none;
transition: transform 160ms ease, background 160ms ease, color 160ms ease, box-shadow 160ms ease;
font-size: 0.95rem;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sentinel-ink);
background: rgba(114, 163, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(114, 163, 255, 0.2);
transform: translateX(3px);
}
.nav-icon {
width: 18px;
}
.sidebar-note {
margin-top: auto;
padding: 16px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(244, 248, 255, 0.98), rgba(235, 243, 255, 0.92));
color: var(--sentinel-ink);
border: 1px solid rgba(122, 164, 255, 0.18);
}
.sidebar-note h3 {
margin: 10px 0;
font-size: 1rem;
}
.shell-main {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.shell-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.shell-content {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.shell-glow {
position: fixed;
inset: auto;
z-index: 0;
border-radius: 999px;
filter: blur(90px);
opacity: 0.7;
pointer-events: none;
}
.shell-glow--mint {
top: 60px;
right: 120px;
width: 220px;
height: 220px;
background: rgba(132, 196, 255, 0.2);
}
.shell-glow--amber {
bottom: 80px;
left: 360px;
width: 260px;
height: 260px;
background: rgba(177, 221, 255, 0.18);
}
@media (max-width: 1080px) {
.shell {
grid-template-columns: 1fr;
}
.shell-sidebar {
order: 2;
}
.shell-main {
order: 1;
}
}
@media (max-width: 720px) {
.shell {
padding: 16px;
}
.shell-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>