mateclaw/mateclaw-ui/src/views/layout/MainLayout.vue

1227 lines
35 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.

<template>
<div class="app-layout">
<!-- 移动端背景遮罩 -->
<Transition name="fade">
<div v-if="isMobile && mobileMenuOpen" class="sidebar-backdrop" @click="mobileMenuOpen = false"></div>
</Transition>
<!-- 左侧导航栏 -->
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed && !isMobile, 'mobile-open': mobileMenuOpen }">
<!-- Logo -->
<div class="sidebar-logo">
<div class="logo-icon">
<img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="logo-img" />
</div>
<transition name="fade">
<div v-if="!effectiveCollapsed" class="logo-text">
<span class="logo-name">Mate<span class="logo-name-highlight">Claw</span></span>
<span class="logo-version">v{{ appVersion }}</span>
</div>
</transition>
<button
class="collapse-btn"
:title="sidebarToggleLabel"
:aria-label="sidebarToggleLabel"
@click="toggleSidebar"
>
<svg v-if="!effectiveCollapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
<!-- 工作区切换 -->
<WorkspaceSwitcher :collapsed="effectiveCollapsed" />
<!-- 导航菜单 -->
<nav class="sidebar-nav">
<template v-for="group in navGroups" :key="group.key">
<div class="nav-group">
<div v-if="!effectiveCollapsed" class="nav-group-title">{{ group.label }}</div>
<router-link
v-for="item in group.items"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: isNavItemActive(item), 'has-attention': item.path === '/backstage' && backstageAlertActive }"
:title="effectiveCollapsed ? (item.tooltip || item.label) : (item.tooltip || '')"
@click="onNavClick"
>
<span class="nav-icon" v-html="item.icon"></span>
<span v-if="!effectiveCollapsed" class="nav-label">{{ item.label }}</span>
<span
v-if="item.path === '/backstage' && backstageAlertActive"
class="nav-attention-dot"
:title="t('backstage.attention')"
></span>
</router-link>
</div>
</template>
</nav>
<!-- 底部 -->
<div class="sidebar-footer">
<template v-if="!sidebarCollapsed || isMobile">
<!-- Doctor 健康指示器 -->
<button class="health-indicator" :class="healthStatus" @click="showDoctor = true" :title="t('doctor.title')">
<span class="health-dot"></span>
<span class="health-label">{{ t('doctor.title') }}</span>
</button>
<div class="sidebar-utility-card">
<div class="compact-utility-row">
<span class="compact-utility-title">{{ t('nav.themeLabel') }}</span>
<div class="theme-toggle-row theme-toggle-row--compact">
<button
v-for="opt in themeOptions"
:key="opt.value"
class="theme-btn theme-btn--compact"
:class="{ active: themeStore.mode === opt.value }"
:title="opt.label"
@click="themeStore.setMode(opt.value)"
>
<span v-html="opt.icon"></span>
</button>
</div>
</div>
<div class="compact-utility-row">
<span class="compact-utility-title">{{ t('nav.languageLabel') }}</span>
<div class="language-toggle-row language-toggle-row--compact">
<button
v-for="opt in localeOptions"
:key="opt.value"
class="language-btn language-btn--compact"
:class="{ active: currentLocaleValue === opt.value }"
@click="changeLocale(opt.value)"
>
<span class="language-abbr">{{ opt.short }}</span>
</button>
</div>
</div>
</div>
<div class="user-info">
<div class="user-avatar">{{ userInitial }}</div>
<div class="user-detail">
<div class="user-name">{{ username }}</div>
<div class="user-role">{{ roleLabel }}</div>
</div>
<button class="change-password-btn" @click="showChangePassword = true" :title="t('auth.changePassword')">
<el-icon :size="16"><Lock /></el-icon>
</button>
<button class="logout-btn" @click="logout" :title="t('nav.logout')">
<el-icon :size="16"><SwitchButton /></el-icon>
</button>
</div>
</template>
<template v-else>
<div class="collapsed-footer-actions">
<button class="footer-icon-btn" :class="healthStatus" @click="showDoctor = true" :title="t('doctor.title')">
<span class="health-dot"></span>
</button>
<button class="footer-icon-btn" :title="t('nav.logout')" @click="logout">
<el-icon :size="16"><SwitchButton /></el-icon>
</button>
</div>
</template>
</div>
</aside>
<!-- 主内容区 -->
<main class="main-content">
<!-- 移动端顶部栏 -->
<div v-if="isMobile" class="mobile-topbar">
<button class="mobile-menu-btn" @click="mobileMenuOpen = true" :title="t('common.expandSidebar')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span>
</div>
<!-- RFC-074 PR-1 fix: include route.path in the key so two different
keepAlive routes (e.g. /channels and /settings/models) don't collide
on the same vnode slot. Without this, switching between two keep-alive
routes leaves both component trees mounted because Vue sees identical
keys and patches in place. The comment must live OUTSIDE <keep-alive>
— KeepAlive treats comments as children and rejects "more than one". -->
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" :key="`${workspaceRouteKey}:${route.path}`" v-if="route.meta?.keepAlive" />
</keep-alive>
<component :is="Component" :key="`${workspaceRouteKey}:${route.path}`" v-if="!route.meta?.keepAlive" />
</router-view>
</main>
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
<DoctorDrawer :visible="showDoctor" @close="showDoctor = false" @status="onHealthStatus" />
<ChangePasswordDialog v-model:visible="showChangePassword" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useThemeStore } from '@/stores/useThemeStore'
import { version as appVersion } from '../../../package.json'
import type { ThemeMode } from '@/stores/useThemeStore'
import { http, settingsApi, setupApi, backstageApi } from '@/api/index'
import OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue'
import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { applyLocale, currentLocale, type AppLocale } from '@/i18n'
import { SwitchButton, Lock } from '@element-plus/icons-vue'
import ChangePasswordDialog from '@/components/ChangePasswordDialog.vue'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const themeStore = useThemeStore()
const workspaceStore = useWorkspaceStore()
const sidebarCollapsed = ref(localStorage.getItem('mc-sidebar-collapsed') === 'true')
const footerPanelOpen = ref(false)
// Workspace 切换时通过 key 变化让 router-view 重新挂载,避免 hard reload 破坏运行状态
const workspaceRouteKey = computed(() => `ws-${workspaceStore.currentWorkspaceId ?? 'none'}`)
const showOnboarding = ref(false)
const showDoctor = ref(false)
const healthStatus = ref('unknown')
function onHealthStatus(status: string) {
healthStatus.value = status
}
async function fetchHealthStatus() {
try {
const res: any = await http.get('/system/health')
const data = res?.data || res
healthStatus.value = data?.overall || 'healthy'
} catch {
healthStatus.value = 'unknown'
}
}
// Live attention signal for the Backstage sidebar entry. Admins only —
// non-admin users never poll the runtime endpoint and never see the dot.
const backstageStuckCount = ref(0)
let backstagePollTimer: ReturnType<typeof setInterval> | null = null
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
const backstageAlertActive = computed(() => isAdminRole.value && backstageStuckCount.value > 0)
async function refreshBackstageBadge() {
if (!isAdminRole.value) return
try {
const res: any = await backstageApi.snapshot()
const data = res?.data ?? res
backstageStuckCount.value = data?.summary?.stuck ?? 0
} catch {
// Silent: stale value is preferable to a flapping indicator.
}
}
// 移动端状态
const isMobile = ref(false)
const mobileMenuOpen = ref(false)
let mobileQuery: MediaQueryList | null = null
// 中等屏幕自动折叠≤1024px
let mediumQuery: MediaQueryList | null = null
const userExplicitCollapse = ref(localStorage.getItem('mc-sidebar-collapsed') === 'true')
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
isMobile.value = e.matches
if (!e.matches) mobileMenuOpen.value = false
if (e.matches) footerPanelOpen.value = false
}
function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches && !userExplicitCollapse.value) {
sidebarCollapsed.value = true
} else if (!e.matches && !userExplicitCollapse.value) {
sidebarCollapsed.value = false
}
}
onMounted(async () => {
mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery)
mobileQuery.addEventListener('change', handleMobileChange)
mediumQuery = window.matchMedia('(max-width: 1024px)')
handleMediumChange(mediumQuery)
mediumQuery.addEventListener('change', handleMediumChange)
// Check onboarding status
if (!localStorage.getItem('mc-onboarding-done')) {
try {
const res: any = await setupApi.onboardingStatus()
if (res?.data && !res.data.hasDefaultModel) {
showOnboarding.value = true
}
} catch {
// If endpoint doesn't exist yet, skip onboarding
}
}
// Fetch initial health status for sidebar indicator
fetchHealthStatus()
// Backstage attention dot — poll every 15s for admins.
if (isAdminRole.value) {
refreshBackstageBadge()
backstagePollTimer = setInterval(refreshBackstageBadge, 15_000)
}
})
onBeforeUnmount(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
mediumQuery?.removeEventListener('change', handleMediumChange)
if (backstagePollTimer) clearInterval(backstagePollTimer)
})
function onNavClick() {
if (isMobile.value) mobileMenuOpen.value = false
}
const username = computed(() => localStorage.getItem('username') || 'User')
const role = computed(() => localStorage.getItem('role') || 'user')
const userInitial = computed(() => username.value.charAt(0).toUpperCase())
const roleLabel = computed(() => role.value === 'admin' ? t('nav.roleAdmin') : t('nav.roleUser'))
const effectiveCollapsed = computed(() => sidebarCollapsed.value && !isMobile.value)
const sidebarToggleLabel = computed(() => sidebarCollapsed.value ? t('common.expandSidebar') : t('common.collapseSidebar'))
const currentLocaleValue = computed(() => currentLocale.value)
const themeOptions = computed<{ value: ThemeMode; label: string; icon: string }[]>(() => [
{
value: 'light',
label: t('nav.themeLight'),
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>',
},
{
value: 'dark',
label: t('nav.themeDark'),
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
},
{
value: 'system',
label: t('nav.themeSystem'),
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
},
])
const localeOptions = computed<{ value: AppLocale; label: string; short: string }[]>(() => [
{ value: 'zh-CN', label: t('settings.languageOptions.zhCN'), short: '中' },
{ value: 'en-US', label: t('settings.languageOptions.enUS'), short: 'EN' },
])
const navGroups = computed(() => [
{
key: 'core',
label: t('nav.core'),
items: [
{
path: '/dashboard',
label: t('nav.dashboard', 'Dashboard'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
},
{
path: '/chat',
label: t('nav.chat'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
},
{
path: '/agents',
label: t('nav.agents'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`,
},
...(isAdminRole.value ? [{
path: '/backstage',
label: t('nav.backstage'),
tooltip: t('nav.backstageTooltip'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`,
}] : []),
{
path: '/wiki',
label: t('nav.wiki'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
},
{
path: '/memory',
label: t('nav.memory'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M16 14H8a4 4 0 0 0-4 4v2h16v-2a4 4 0 0 0-4-4z"/><line x1="12" y1="11" x2="12" y2="14"/></svg>`,
},
{
path: '/enterprise',
label: t('nav.enterprise'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 9h.01"/><path d="M9 12h.01"/><path d="M9 15h.01"/><path d="M9 18h.01"/><path d="M15 9h.01"/><path d="M15 12h.01"/><path d="M15 15h.01"/><path d="M15 18h.01"/></svg>`,
},
],
},
{
key: 'connect',
label: t('nav.connect'),
items: [
{
path: '/channels',
label: t('nav.channels'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 1.18h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.73a16 16 0 0 0 6.29 6.29l1.62-1.62a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`,
},
{
path: '/skills',
label: t('nav.skills'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
},
{
path: '/plugins',
label: t('nav.plugins'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 3h-8v4h8V3z"/></svg>`,
},
// RFC-090 Phase 4: Activity 提升到顶层
{
path: '/activity',
label: t('nav.activity'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
},
],
},
{
key: 'system',
label: t('nav.system'),
items: [
{
path: '/settings/models',
label: t('nav.settings'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>`,
},
{
path: '/security',
label: t('nav.security'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
},
],
},
])
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
userExplicitCollapse.value = sidebarCollapsed.value
localStorage.setItem('mc-sidebar-collapsed', String(sidebarCollapsed.value))
if (!sidebarCollapsed.value) {
footerPanelOpen.value = false
}
}
function isNavItemActive(item: { path: string; label: string }) {
if (item.path.startsWith('/settings')) {
return route.path.startsWith('/settings')
}
if (item.path === '/security') {
return route.path.startsWith('/security')
}
return route.path === item.path
}
const showChangePassword = ref(false)
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('role')
router.push('/login')
}
async function changeLocale(locale: AppLocale) {
await applyLocale(locale)
footerPanelOpen.value = false
try {
await settingsApi.update({ language: locale })
} catch {
// keep local preference even if backend persistence fails
}
}
watch(() => route.fullPath, () => {
footerPanelOpen.value = false
if (isMobile.value) mobileMenuOpen.value = false
})
watch(() => sidebarCollapsed.value, (collapsed) => {
if (!collapsed) footerPanelOpen.value = false
})
watch(() => workspaceStore.currentWorkspaceId, () => {
footerPanelOpen.value = false
})
</script>
<style scoped>
.app-layout {
display: flex;
height: 100vh;
background: var(--mc-bg);
overflow: hidden;
position: relative;
}
.app-layout::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at top left, rgba(217, 109, 70, 0.12), transparent 22%),
radial-gradient(circle at bottom right, rgba(24, 74, 69, 0.08), transparent 18%);
pointer-events: none;
}
:global(html.dark) .app-layout::before {
background:
radial-gradient(circle at top left, rgba(235, 143, 101, 0.14), transparent 24%),
radial-gradient(circle at bottom right, rgba(92, 166, 157, 0.08), transparent 20%);
}
/* ===== 侧边栏 ===== */
.sidebar {
width: 236px;
min-width: 236px;
margin: 14px 0 14px 14px;
background:
linear-gradient(180deg, var(--mc-panel-top), var(--mc-panel-bottom));
border: 1px solid var(--mc-sidebar-border);
border-radius: 28px;
box-shadow: var(--mc-shadow-soft);
display: flex;
flex-direction: column;
transition: width 0.2s ease, min-width 0.2s ease;
overflow: hidden;
position: relative;
z-index: 1;
}
.sidebar.collapsed {
width: 74px;
min-width: 74px;
}
.sidebar::before {
content: '';
position: absolute;
inset: 0;
background: var(--mc-glow);
pointer-events: none;
}
.sidebar-logo {
display: flex;
align-items: center;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--mc-border-light);
gap: 12px;
min-height: 64px;
}
.sidebar.collapsed .sidebar-logo {
flex-direction: column;
justify-content: center;
padding: 12px 10px;
gap: 6px;
min-height: 92px;
}
.logo-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
background: linear-gradient(135deg, rgba(217, 109, 70, 0.18), rgba(24, 74, 69, 0.08));
border: 1px solid rgba(217, 109, 70, 0.14);
}
.logo-img {
width: 34px;
height: 34px;
object-fit: contain;
filter: drop-shadow(0 8px 18px rgba(217, 109, 70, 0.22));
}
.logo-emoji { font-size: 16px; }
.logo-text { flex: 1; overflow: hidden; }
.logo-name {
display: block;
font-size: 16px;
font-weight: 800;
color: var(--mc-sidebar-logo-name);
white-space: nowrap;
letter-spacing: -0.03em;
}
.logo-name-highlight {
color: var(--mc-primary);
}
.logo-version {
display: block;
font-size: 10px;
color: var(--mc-text-tertiary);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.collapse-btn {
width: 28px;
height: 28px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
cursor: pointer;
color: var(--mc-text-tertiary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
flex-shrink: 0;
padding: 0;
margin-left: auto;
}
.collapse-btn:hover {
background: var(--mc-sidebar-hover);
color: var(--mc-text-primary);
}
.sidebar.collapsed .collapse-btn {
width: 32px;
height: 32px;
margin-left: 0;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
color: var(--mc-sidebar-text-active);
}
.sidebar.collapsed .collapse-btn:hover {
background: var(--mc-sidebar-hover);
border-color: var(--mc-border);
}
/* 导航 */
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 8px 0 4px;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--mc-border); border-radius: 2px; }
.sidebar-nav::-webkit-scrollbar { display: none; }
.nav-group { margin-bottom: 2px; }
.nav-group-title {
padding: 8px 18px 4px;
font-size: 10px;
font-weight: 600;
color: var(--mc-sidebar-group-title);
text-transform: uppercase;
letter-spacing: 0.1em;
white-space: nowrap;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
color: var(--mc-sidebar-text);
text-decoration: none;
font-size: 13px;
border-radius: 13px;
margin: 2px 10px;
transition: all 0.15s ease;
white-space: nowrap;
overflow: hidden;
position: relative;
}
.nav-item:hover {
background: var(--mc-sidebar-hover);
color: var(--mc-text-primary);
}
.nav-item.active {
background: var(--mc-sidebar-active);
color: var(--mc-sidebar-text-active);
font-weight: 600;
box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.08);
}
/* Active indicator bar removed — active state uses bg color + font weight only */
.nav-icon { display: flex; align-items: center; flex-shrink: 0; }
.nav-label { overflow: hidden; text-overflow: ellipsis; }
/* Backstage attention dot — appears only when stuck > 0 for an admin. */
.nav-attention-dot {
position: absolute;
right: 14px;
top: 50%;
width: 8px;
height: 8px;
border-radius: 50%;
background: hsl(20, 80%, 55%);
transform: translateY(-50%);
box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.6);
animation: nav-attention-pulse 2.4s ease-in-out infinite;
}
.nav-item.has-attention {
color: hsl(20, 75%, 50%);
}
@keyframes nav-attention-pulse {
0%, 100% { box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.55); transform: translateY(-50%) scale(1); }
50% { box-shadow: 0 0 0 6px hsla(20, 80%, 55%, 0); transform: translateY(-50%) scale(1.15); }
}
.sidebar.collapsed .nav-attention-dot {
right: 8px;
top: 8px;
transform: none;
}
.sidebar.collapsed .nav-attention-dot {
animation-name: nav-attention-pulse-collapsed;
}
@keyframes nav-attention-pulse-collapsed {
0%, 100% { box-shadow: 0 0 0 0 hsla(20, 80%, 55%, 0.55); transform: scale(1); }
50% { box-shadow: 0 0 0 5px hsla(20, 80%, 55%, 0); transform: scale(1.2); }
}
/* 底部 */
.sidebar-footer {
border-top: 1px solid var(--mc-border-light);
padding: 10px 12px 12px;
background: var(--mc-sidebar-footer-bg);
backdrop-filter: blur(14px);
position: relative;
}
.health-indicator { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-muted); border-radius: 12px; cursor: pointer; color: var(--mc-text-secondary); font-size: 12px; margin-bottom: 8px; }
.health-indicator:hover { background: var(--mc-bg-sunken); }
.health-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.health-indicator.healthy .health-dot { background: var(--mc-success); }
.health-indicator.warning .health-dot { background: var(--mc-primary); }
.health-indicator.error .health-dot { background: var(--mc-danger); }
.health-indicator.unknown .health-dot { background: var(--mc-text-tertiary); }
.sidebar-utility-card {
margin-bottom: 8px;
padding: 8px 10px;
border-radius: 16px;
border: 1px solid var(--mc-border-light);
background: color-mix(in srgb, var(--mc-sidebar-footer-bg) 74%, transparent);
}
.compact-utility-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.compact-utility-row + .compact-utility-row {
margin-top: 6px;
}
.compact-utility-title {
font-size: 10px;
font-weight: 700;
color: var(--mc-text-secondary);
letter-spacing: 0.04em;
white-space: nowrap;
}
.utility-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--mc-text-tertiary); margin: 0 0 8px; padding-left: 2px; }
/* 主题切换 */
.theme-toggle-row {
display: flex;
gap: 2px;
background: var(--mc-bg-muted);
border-radius: 14px;
padding: 4px;
margin-bottom: 12px;
border: 1px solid var(--mc-border-light);
}
.theme-toggle-row--compact {
margin-bottom: 0;
padding: 2px;
gap: 3px;
border-radius: 999px;
}
.language-toggle-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.language-toggle-row--compact {
display: flex;
gap: 6px;
}
.theme-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 5px 4px;
border: none;
background: transparent;
color: var(--mc-text-tertiary);
border-radius: 6px;
cursor: pointer;
font-size: 11px;
transition: all 0.15s ease;
white-space: nowrap;
}
.theme-btn--compact {
width: 28px;
height: 28px;
padding: 0;
border-radius: 999px;
flex: 0 0 auto;
}
.theme-btn:hover {
color: var(--mc-text-secondary);
}
.theme-btn.active {
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
box-shadow: var(--mc-shadow-soft);
}
.theme-btn-label {
overflow: hidden;
text-overflow: ellipsis;
}
.language-btn {
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
width: 100%;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
font-size: 12px;
font-weight: 600;
}
.language-btn:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.language-btn.active {
border-color: rgba(217, 109, 70, 0.18);
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.language-abbr {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mc-panel-raised);
color: inherit;
font-size: 11px;
font-weight: 800;
flex-shrink: 0;
}
.language-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.language-btn--compact {
width: 34px;
min-width: 34px;
justify-content: center;
padding: 4px 0;
border-radius: 999px;
}
.language-btn--compact .language-abbr {
width: 20px;
height: 20px;
font-size: 10px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 9px;
border-radius: 14px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
}
.user-avatar {
width: 30px;
height: 30px;
background: linear-gradient(135deg, var(--mc-primary), var(--mc-accent));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.user-detail { flex: 1; overflow: hidden; }
.user-name {
font-size: 12px;
font-weight: 500;
color: var(--mc-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role { font-size: 10px; color: var(--mc-text-tertiary); }
.change-password-btn,
.logout-btn {
width: 26px;
height: 26px;
border: none;
background: none;
cursor: pointer;
color: var(--mc-text-tertiary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
padding: 0;
flex-shrink: 0;
}
.change-password-btn:hover {
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.logout-btn:hover {
background: var(--mc-danger-bg);
color: var(--mc-danger);
}
.collapsed-footer-actions {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.footer-icon-btn {
width: 42px;
height: 42px;
border-radius: 14px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.footer-icon-btn:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.footer-icon-btn.healthy .health-dot { background: var(--mc-success); }
.footer-icon-btn.warning .health-dot { background: var(--mc-primary); }
.footer-icon-btn.error .health-dot { background: var(--mc-danger); }
.footer-icon-btn.unknown .health-dot { background: var(--mc-text-tertiary); }
.footer-icon-btn--accent {
color: var(--mc-primary);
background: var(--mc-primary-bg);
border-color: rgba(217, 109, 70, 0.18);
}
.sidebar-utility-panel {
position: absolute;
left: calc(100% + 14px);
bottom: 16px;
width: 236px;
padding: 14px;
border-radius: 22px;
background: var(--mc-sidebar-floating-bg);
border: 1px solid var(--mc-border);
box-shadow: var(--mc-shadow-medium);
display: flex;
flex-direction: column;
gap: 14px;
backdrop-filter: blur(18px);
}
.panel-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-option-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.panel-option-btn {
width: 100%;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.15s ease;
}
.panel-option-btn:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.panel-option-btn.active {
background: var(--mc-primary-bg);
color: var(--mc-primary);
border-color: rgba(217, 109, 70, 0.18);
}
.panel-option-icon {
width: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.panel-user {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 16px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
}
.panel-user-meta {
min-width: 0;
flex: 1;
}
.logout-btn--panel {
flex-shrink: 0;
}
/* ===== 主内容区 ===== */
.main-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
padding: 14px 14px 14px 18px;
}
/* ===== 移动端元素(桌面端隐藏) ===== */
.sidebar-backdrop {
display: none;
}
.mobile-topbar {
display: none;
}
/* 动画 */
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 1000;
width: 260px;
min-width: 260px;
margin: 10px;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: var(--mc-shadow-medium);
}
.sidebar.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
.sidebar.collapsed {
width: 260px;
min-width: 260px;
}
.collapse-btn {
display: none;
}
.sidebar-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.3);
}
.mobile-topbar {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 12px;
padding: 12px 14px;
background: var(--mc-surface-overlay);
border: 1px solid var(--mc-border);
border-radius: 18px;
box-shadow: var(--mc-shadow-soft);
flex-shrink: 0;
}
.mobile-topbar-title {
font-size: 16px;
font-weight: 700;
color: var(--mc-text-primary);
}
.mobile-menu-btn {
width: 36px;
height: 36px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--mc-text-primary);
flex-shrink: 0;
}
.mobile-menu-btn:hover {
background: var(--mc-bg-sunken);
}
.sidebar-utility-panel {
display: none;
}
.sidebar-utility-card {
padding: 10px;
}
.compact-utility-row {
flex-direction: column;
align-items: stretch;
}
.theme-toggle-row--compact,
.language-toggle-row--compact {
width: 100%;
justify-content: stretch;
}
.theme-btn--compact,
.language-btn--compact {
flex: 1;
width: auto;
}
.sidebar-footer {
background: transparent;
backdrop-filter: none;
}
}
@media (max-width: 480px) {
.mobile-topbar {
padding: 8px 10px;
margin: 0 0 8px;
border-radius: 12px;
gap: 8px;
}
.mobile-topbar-title {
font-size: 14px;
}
.mobile-menu-btn {
width: 32px;
height: 32px;
}
.main-content {
padding: 8px;
}
}
</style>