2026-09-11 22:46:31 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-10-02 11:18:47 +08:00
|
|
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
2026-09-11 22:46:31 +08:00
|
|
|
|
import { useRoute, useRouter } from 'vue-router';
|
2026-10-02 11:18:47 +08:00
|
|
|
|
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
|
2026-09-11 22:46:31 +08:00
|
|
|
|
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
2026-10-02 00:25:10 +08:00
|
|
|
|
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
|
2026-09-11 22:46:31 +08:00
|
|
|
|
import { useAppStore } from '../stores/app.js';
|
|
|
|
|
|
import { useAuthStore } from '../stores/auth.js';
|
|
|
|
|
|
import { useNotificationStore } from '../stores/notifications.js';
|
|
|
|
|
|
import { useRealtimeStore } from '../stores/realtime.js';
|
2026-10-02 11:18:47 +08:00
|
|
|
|
import { useUiStore } from '../stores/ui.js';
|
2026-09-11 22:46:31 +08:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const appStore = useAppStore();
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const realtime = useRealtimeStore();
|
|
|
|
|
|
const notifications = useNotificationStore();
|
2026-10-02 11:18:47 +08:00
|
|
|
|
const ui = useUiStore();
|
2026-09-11 22:46:31 +08:00
|
|
|
|
|
2026-10-02 11:18:47 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 主侧边栏:ClassIsland 式「分组 + 子菜单」。
|
|
|
|
|
|
* - 学习:课表 / 作业 / 通知 / 成绩
|
|
|
|
|
|
* - 设置:通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于(每个子项都是独立路由 /settings/*)
|
|
|
|
|
|
* 顶栏左侧汉堡按钮把整栏折叠成图标栏(状态持久化在主进程配置里)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const menuGroups = [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '学习',
|
|
|
|
|
|
icon: 'Reading',
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{ path: '/schedule', title: '课表', icon: 'Calendar' },
|
|
|
|
|
|
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
|
|
|
|
|
|
{ path: '/notifications', title: '通知', icon: 'Bell' },
|
|
|
|
|
|
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '设置',
|
|
|
|
|
|
icon: 'Setting',
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{ path: '/settings/general', title: '通用', icon: 'Tools' },
|
|
|
|
|
|
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
|
|
|
|
|
|
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
|
|
|
|
|
|
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
|
|
|
|
|
|
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
|
|
|
|
|
|
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
2026-09-11 22:46:31 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-10-02 11:18:47 +08:00
|
|
|
|
/** 未读红点挂在「通知」项的图标右上角 */
|
|
|
|
|
|
function showBadge(path: string): boolean {
|
|
|
|
|
|
return path === '/notifications' && notifications.unreadCount > 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 00:20:21 +08:00
|
|
|
|
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
|
|
|
|
|
const activeMenu = computed(() => route.path);
|
|
|
|
|
|
|
2026-10-02 11:18:47 +08:00
|
|
|
|
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
|
|
|
|
|
|
const activeGroupTitle = computed(() => {
|
|
|
|
|
|
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
|
|
|
|
|
|
return group?.title ?? '学习';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 分组展开控制:默认只展开**当前路由所在的分组**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 窗口默认高度与 ClassIsland 对齐(582px),两组全展开(10 个子项 + 2 个分组标题)
|
|
|
|
|
|
* 会超出可视高度、把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的
|
|
|
|
|
|
* 观感(它也只展开当前组),又让小窗口下的侧栏始终完整可见。
|
|
|
|
|
|
* 用户手动展开其它分组不受限制(可多个同时展开)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const menuRef = ref<MenuInstance>();
|
|
|
|
|
|
const openedGroups = ref<string[]>([activeGroupTitle.value]);
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => route.path,
|
|
|
|
|
|
async (path) => {
|
|
|
|
|
|
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
|
|
|
|
|
|
if (!group) return;
|
|
|
|
|
|
// 展开当前组(不主动关闭用户已展开的其它组)
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
menuRef.value?.open(group.title);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-12 00:20:21 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 菜单点击回调。
|
|
|
|
|
|
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
|
|
|
|
|
|
* 不能当作路由名传给 router.push({ name }),否则会静默失败(点击无反应)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function handleMenuSelect(index: string): void {
|
|
|
|
|
|
void router.push(index);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 按路由名跳转(下拉菜单、按钮等程序化调用) */
|
|
|
|
|
|
function go(name: string): void {
|
|
|
|
|
|
void router.push({ name });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 开发期自检:菜单路径必须存在于路由表中 */
|
|
|
|
|
|
if (import.meta.env.DEV) {
|
|
|
|
|
|
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
2026-10-02 11:18:47 +08:00
|
|
|
|
for (const group of menuGroups) {
|
|
|
|
|
|
for (const item of group.items) {
|
|
|
|
|
|
if (!knownPaths.has(item.path)) {
|
|
|
|
|
|
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
|
|
|
|
|
}
|
2026-09-12 00:20:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-11 22:46:31 +08:00
|
|
|
|
|
|
|
|
|
|
const connectionType = computed(() => {
|
|
|
|
|
|
if (realtime.connected) return 'success';
|
|
|
|
|
|
if (appStore.serverReachable) return 'warning';
|
|
|
|
|
|
return 'danger';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const connectionText = computed(() => {
|
|
|
|
|
|
if (realtime.connected) return '实时连接正常';
|
|
|
|
|
|
if (appStore.serverReachable) return '服务器可达(实时通道重连中)';
|
|
|
|
|
|
return '离线模式 · 显示缓存数据';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function handleLogout(): Promise<void> {
|
|
|
|
|
|
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' });
|
|
|
|
|
|
realtime.disconnect();
|
|
|
|
|
|
await auth.logout();
|
|
|
|
|
|
ElMessage.success('已退出登录');
|
|
|
|
|
|
void router.push({ name: 'login' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 00:25:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 通知实时推送:更新通知中心列表与红点。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 这里**不再**额外弹一条"收到新通知"的 toast —— `stores/realtime.ts` 收到
|
|
|
|
|
|
* notification:new 时已经弹过带优先级与标题的那条,两条堆在屏幕上纯属噪音。
|
|
|
|
|
|
*/
|
2026-09-11 22:46:31 +08:00
|
|
|
|
function onNotification(payload: unknown): void {
|
|
|
|
|
|
void notifications.pushRealtime(payload as NotificationDto);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
|
|
|
|
|
|
function onRecovered(): void {
|
|
|
|
|
|
ElMessage.success('已重新连接服务器,正在同步最新数据');
|
|
|
|
|
|
void notifications.load();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await notifications.load().catch(() => undefined);
|
|
|
|
|
|
await notifications.refreshUnreadCount();
|
|
|
|
|
|
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
|
|
|
|
|
|
appStore.onServerRecovered(onRecovered);
|
2026-10-02 00:25:10 +08:00
|
|
|
|
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
|
|
|
|
|
|
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
|
|
|
|
|
|
if (auth.token) startIslandBridge();
|
2026-09-11 22:46:31 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
|
|
|
|
|
|
appStore.offServerRecovered(onRecovered);
|
2026-10-02 00:25:10 +08:00
|
|
|
|
stopIslandBridge();
|
2026-09-11 22:46:31 +08:00
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<el-container class="layout">
|
2026-10-02 11:18:47 +08:00
|
|
|
|
<el-aside :width="ui.sidebarCollapsed ? '64px' : '200px'" class="aside ch-nav">
|
2026-09-11 22:46:31 +08:00
|
|
|
|
<div class="brand">
|
2026-10-02 11:18:47 +08:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="nav-toggle"
|
|
|
|
|
|
aria-label="折叠或展开侧边栏"
|
|
|
|
|
|
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
|
|
|
|
|
|
data-test="sidebar-toggle"
|
|
|
|
|
|
@click="ui.toggleSidebar()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<template v-if="!ui.sidebarCollapsed">
|
|
|
|
|
|
<span class="brand-logo">
|
|
|
|
|
|
<el-icon :size="16"><School /></el-icon>
|
2026-09-13 08:39:11 +08:00
|
|
|
|
</span>
|
2026-10-02 11:18:47 +08:00
|
|
|
|
<span class="brand-text">班级小助手</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-menu
|
|
|
|
|
|
ref="menuRef"
|
|
|
|
|
|
:default-active="activeMenu"
|
|
|
|
|
|
:default-openeds="openedGroups"
|
|
|
|
|
|
:collapse="ui.sidebarCollapsed"
|
|
|
|
|
|
:collapse-width="64"
|
|
|
|
|
|
class="menu"
|
|
|
|
|
|
@select="handleMenuSelect"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
|
|
|
|
|
|
<template #title>
|
|
|
|
|
|
<el-icon><component :is="group.icon" /></el-icon>
|
|
|
|
|
|
<span>{{ group.title }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
|
|
|
|
|
|
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
|
|
|
|
|
|
<span class="menu-icon-slot">
|
|
|
|
|
|
<el-icon><component :is="item.icon" /></el-icon>
|
|
|
|
|
|
<el-badge v-if="showBadge(item.path)" :value="notifications.unreadCount" :max="99" class="menu-badge" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>{{ item.title }}</span>
|
|
|
|
|
|
</el-menu-item>
|
|
|
|
|
|
</el-sub-menu>
|
2026-09-11 22:46:31 +08:00
|
|
|
|
</el-menu>
|
2026-10-02 11:18:47 +08:00
|
|
|
|
<div v-if="!ui.sidebarCollapsed" class="aside-footer">
|
2026-09-11 22:46:31 +08:00
|
|
|
|
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
|
|
|
|
|
|
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-aside>
|
|
|
|
|
|
|
|
|
|
|
|
<el-container>
|
|
|
|
|
|
<el-header class="header">
|
|
|
|
|
|
<div class="header-left">
|
|
|
|
|
|
<el-tag :type="connectionType" size="small" effect="light">{{ connectionText }}</el-tag>
|
|
|
|
|
|
<el-tag v-if="auth.offlineSession" type="warning" size="small" effect="plain">离线会话</el-tag>
|
|
|
|
|
|
<span class="server-url">{{ appStore.serverUrl }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="header-right">
|
2026-10-02 11:18:47 +08:00
|
|
|
|
<!-- 黑夜/白天快捷切换:外观页里也有同样的设置,两处写同一份配置 -->
|
|
|
|
|
|
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
class="theme-toggle"
|
|
|
|
|
|
text
|
|
|
|
|
|
circle
|
|
|
|
|
|
data-test="theme-toggle"
|
|
|
|
|
|
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
|
|
|
|
|
|
@click="ui.toggleTheme()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</el-tooltip>
|
2026-09-11 22:46:31 +08:00
|
|
|
|
<el-dropdown trigger="click">
|
|
|
|
|
|
<span class="user-chip">
|
|
|
|
|
|
<el-icon><UserFilled /></el-icon>
|
|
|
|
|
|
{{ auth.displayName }}
|
|
|
|
|
|
<el-icon><ArrowDown /></el-icon>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<template #dropdown>
|
|
|
|
|
|
<el-dropdown-menu>
|
2026-10-02 11:18:47 +08:00
|
|
|
|
<el-dropdown-item @click="go('settings-general')">
|
2026-09-11 22:46:31 +08:00
|
|
|
|
<el-icon><Setting /></el-icon>
|
|
|
|
|
|
设置
|
|
|
|
|
|
</el-dropdown-item>
|
|
|
|
|
|
<el-dropdown-item divided @click="handleLogout">
|
|
|
|
|
|
<el-icon><SwitchButton /></el-icon>
|
|
|
|
|
|
退出登录
|
|
|
|
|
|
</el-dropdown-item>
|
|
|
|
|
|
</el-dropdown-menu>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dropdown>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-header>
|
|
|
|
|
|
|
|
|
|
|
|
<el-main class="main">
|
|
|
|
|
|
<el-alert
|
|
|
|
|
|
v-if="appStore.offline && appStore.initialized"
|
|
|
|
|
|
class="offline-banner"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
title="当前处于离线状态"
|
|
|
|
|
|
:description="`服务器不可达,正在显示本地缓存数据(最近同步:${appStore.lastSyncText})。恢复网络后会自动同步。`"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<router-view v-slot="{ Component }">
|
|
|
|
|
|
<component :is="Component" />
|
|
|
|
|
|
</router-view>
|
|
|
|
|
|
</el-main>
|
|
|
|
|
|
</el-container>
|
|
|
|
|
|
</el-container>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.layout {
|
|
|
|
|
|
height: 100vh;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
background: var(--ch-bg);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.aside {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.brand {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
gap: 10px;
|
|
|
|
|
|
height: 52px;
|
2026-10-02 11:18:47 +08:00
|
|
|
|
padding: 0 10px 0 12px;
|
2026-09-11 22:46:31 +08:00
|
|
|
|
font-weight: 600;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
color: var(--ch-text);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 11:18:47 +08:00
|
|
|
|
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
|
|
|
|
|
|
.nav-toggle {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: var(--ch-radius-control);
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--ch-text-secondary);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: background 0.15s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nav-toggle:hover {
|
|
|
|
|
|
background: var(--ch-hover-soft);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 折叠态:侧栏只剩 64px,汉堡按钮居中 */
|
|
|
|
|
|
.aside.ch-nav:has(.el-menu--collapse) .brand {
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 13:57:46 +08:00
|
|
|
|
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
|
|
|
|
|
|
.brand-logo {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 26px;
|
|
|
|
|
|
height: 26px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 13:57:46 +08:00
|
|
|
|
.brand-text {
|
|
|
|
|
|
letter-spacing: 0.2px;
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 13:57:46 +08:00
|
|
|
|
.menu {
|
|
|
|
|
|
flex: 1;
|
2026-10-02 11:18:47 +08:00
|
|
|
|
/* min-height:0 是 flex 子项能滚动的关键:否则内容撑高、底部 footer 被挤出窗口 */
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
overflow-x: hidden;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
border-right: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
--el-menu-bg-color: transparent;
|
2026-10-02 11:18:47 +08:00
|
|
|
|
--el-menu-hover-bg-color: var(--ch-hover-soft);
|
2026-09-12 13:57:46 +08:00
|
|
|
|
--el-menu-text-color: var(--ch-text);
|
|
|
|
|
|
--el-menu-active-color: var(--ch-text);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
/* 图标槽:作为未读红点的定位父级(红点在图标右上角) */
|
|
|
|
|
|
.menu-icon-slot {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 未读红点:定位到图标"右上角外侧"。
|
|
|
|
|
|
* el-badge 默认把角标中线压在包裹元素右上角(translateY(-50%) translateX(100%)),
|
|
|
|
|
|
* 这里显式覆盖成相对图标槽的负 top / 负 right,保证红点在图标右上方而不是右侧或下方。
|
|
|
|
|
|
*/
|
2026-09-11 22:46:31 +08:00
|
|
|
|
.menu-badge {
|
2026-09-13 08:39:11 +08:00
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
width: 0;
|
|
|
|
|
|
height: 0;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.menu-badge :deep(.el-badge__content) {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: -7px;
|
|
|
|
|
|
right: -11px;
|
|
|
|
|
|
transform: none;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
min-width: 16px;
|
|
|
|
|
|
padding: 0 4px;
|
|
|
|
|
|
line-height: 16px;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
border: none;
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.aside-footer {
|
|
|
|
|
|
padding: 10px 16px 14px;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
border-top: 1px solid var(--ch-divider);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.aside-meta {
|
|
|
|
|
|
font-size: 12px;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
color: var(--ch-text-tertiary);
|
|
|
|
|
|
line-height: 1.9;
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header {
|
2026-09-12 13:57:46 +08:00
|
|
|
|
height: 52px;
|
2026-10-02 11:18:47 +08:00
|
|
|
|
background: var(--ch-header-bg);
|
2026-09-12 13:57:46 +08:00
|
|
|
|
backdrop-filter: blur(20px);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
border-bottom: 1px solid var(--ch-border);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
padding: 0 16px;
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-left {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.server-url {
|
|
|
|
|
|
font-size: 12px;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
color: var(--ch-text-tertiary);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-chip {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
|
border-radius: var(--ch-radius-control);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
outline: none;
|
2026-09-12 13:57:46 +08:00
|
|
|
|
transition: background 0.15s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-chip:hover {
|
2026-10-02 11:18:47 +08:00
|
|
|
|
background: var(--ch-hover-soft);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.theme-toggle {
|
|
|
|
|
|
margin-right: 2px;
|
|
|
|
|
|
color: var(--ch-text-secondary);
|
2026-09-11 22:46:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.main {
|
|
|
|
|
|
background: var(--ch-bg);
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|