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