feat(desktop+web+server): ClassIsland 风格 UI、灵动岛动画/交互修复、作业上岛与"叫人"功能

## 灵动岛修复与动画优化(用户反馈)

- **收回不再出现"方框"闪烁**:卡片改为**固定逻辑尺寸 + 顶部居中锚定**,
  窗口只负责露出/裁切透明区域。此前卡片随窗口拉伸,形变过程中会看到被拉扁的圆角方块,
  胶囊里的铃铛/文字还会被垂直居中而"从上跳到下";两条问题同源,现已消除。
  冒烟用 15 次采样断言卡片尺寸恒为固定值(`396x308` / `260x38`),一旦出现中间值即失败。
- **点击屏幕任意位置收回**:展开态临时 `setFocusable(true)` 并取焦,点到别处即失焦 → 自动回缩为胶囊;
  收起后立即释放可聚焦性。断言覆盖 `focusable=true → 失焦后 mode=pill、focusable=false`。
- 动画继续打磨:危险/叫人形态改为**内部光晕**(卡片四周再留 4px 透明边,彻底避免窗口边界裁切出硬边)、
  尖角与圆角统一、形态尺寸各异(胶囊 268×44 / 详情 404×316 / 紧急 424×344 / 叫人 440×360)。

## 灵动岛"标为已读"真正同步通知中心

- 新增链路:灵动岛 `mark-read` → 主进程 `setMarkReadHandler` → 主窗口渲染进程 →
  `notificationApi.markRead` + 刷新 store(未读红点即时减少)。
- 真机链路断言:点击"标为已读"后 `read=false → true`、未读数 `1 → 0`(此前只从灵动岛队列移除)。

## 作业发布也上岛

- `homework:new` → 灵动岛 `kind=homework`:胶囊显示"新作业"(青蓝描边 + 书本图标),
  展开显示作业说明与"截止时间:…"。

## 新增"叫人"功能

- 服务端:新模块 `modules/calls`(`POST /api/calls`)——校验班级可写 + 学生属于该班,
  落库一条 HIGH 级通知(进通知中心、可用已读状态),广播 `notification:new` 到班级房间,
  并定向 `call:new` 给被叫学生。
- 共享层:`CALL_QUICK_PHRASES`(8 条快捷短语)、`buildCallTitle()`("请 XXX 同学找 XXX 老师",老师名已含"老师"不重复)、
  `ServerToClientEvents['call:new']`、`CreateCallRequest`。
- Web 管理端:学生管理列表新增 **叫人** 按钮 → 弹窗显示对象、快捷短语(点选/取消)、自定义消息(优先),发送即通知。
- 客户端:`call:new` → 灵动岛 `kind=call`,**上课时段也立即展开**(老师正在等这名学生),
  琥珀金卡片 +"叫人"徽标 +"收到"按钮,90 秒后自动收起。

## 客户端界面改为 ClassIsland 同款 Fluent 风格

- 主题令牌:Mica 浅底 `#f3f3f3`、亚克力卡片 `rgba(255,255,255,.7)` + `backdrop-filter`、
  强调色 `#0F6CBD`、半透明描边、卡片 8px / 控件 4px 圆角、极轻阴影。
- Element Plus 变量整体对齐(`--el-color-primary`、圆角、表头/行分隔、滚动条等)。
- 侧边导航改为 Fluent NavigationView 观感:白色半透明底、选中项强调色淡底 + 左侧 3px 强调条;
  品牌标改为强调色渐变圆角方块。附客户端五个页面实拍(`docs/screenshots/client/`)。

## 验证

- `pnpm verify:e2e` **68/68**(新增 6 项叫人:201 + 标题模板、自定义消息优先、上课时段允许、学生 403、通知中心可见、清理)
- `pnpm verify:desktop` **33/33**(新增:卡片固定尺寸、点击任意处收回、标为已读同步、作业上岛、叫人上课中立即展开、真实通知链路)
- `pnpm verify:web` **16/16**(新增叫人入口:弹窗、8 条快捷短语、自定义输入、短语可选中)
- `pnpm lint` / `format:check` / `typecheck` 全部通过;服务端安装包已重打并安装验证
This commit is contained in:
LoveChengke committed 2026-09-12 13:57:46 +08:00
1 parent f759aac0a2
commit 219f6ae83a
28 files changed
+1555 -156

No files matched your search

+4 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { startIslandBridge, stopIslandBridge } from './island/bridge.js';
import { startIslandBridge, stopIslandBridge, subscribeIslandMarkRead } from './island/bridge.js';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useRealtimeStore } from './stores/realtime.js';
@@ -33,6 +33,9 @@ onMounted(async () => {
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
if (auth.token) startIslandBridge();
// 灵动岛"标为已读" → 同步通知中心(未读红点)
subscribeIslandMarkRead();
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
} finally {
+106
View File
@@ -215,6 +215,109 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
}
}
/**
* 冒烟自检 5(需后端在线):真实通知链路 → 灵动岛。
*
* 前面的灵动岛用例直接调 IPC 验证"状态机",这一条走**真机链路**:
* 教师账号给"当前登录学生所在班级"发一条通知 → 服务端 Socket.IO 广播 →
* 客户端 realtime store → bridge → 主进程灵动岛。主进程随后断言灵动岛弹出胶囊,
* 并继续验证"在灵动岛点标为已读 → 通知中心同步为已读"。
*/
export async function islandRealtimeScenario(): Promise<{
ok: boolean;
detail: string;
notificationId?: string;
teacherToken?: string;
title?: string;
classId?: string;
inClass?: boolean;
}> {
const [{ useAuthStore }, { getIslandClassContext }] = await Promise.all([
import('../stores/auth.js'),
import('../island/bridge.js'),
]);
const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000';
const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
const title = `灵动岛真机链路自检 ${Date.now()}`;
try {
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
}).then((response) => response.json());
const teacherToken: string | undefined = login?.data?.token;
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
const created = await fetch(`${serverUrl}/api/notifications`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` },
body: JSON.stringify({
classId,
title,
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
priority: 'NORMAL',
}),
}).then((response) => response.json());
const notificationId: string | undefined = created?.data?.id;
if (!notificationId)
return { ok: false, detail: `发布通知失败:${JSON.stringify(created).slice(0, 160)}` };
const context = getIslandClassContext();
return {
ok: true,
detail: `classId=${classId} 通知=${notificationId} 客户端判定上课中=${context.inClass}`,
notificationId,
teacherToken,
title,
classId,
inClass: context.inClass,
};
} catch (error) {
return { ok: false, detail: `投递失败:${error instanceof Error ? error.message : String(error)}` };
}
}
/** 冒烟收尾:删除真实链路自检产生的通知 */
export async function islandRealtimeCleanup(
notificationId: string,
teacherToken: string,
): Promise<SmokeCheckResult> {
try {
const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${teacherToken}` },
});
return { ok: response.ok, detail: `清理通知 ${notificationId} → HTTP ${response.status}` };
} catch (error) {
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
}
}
/**
* 读取某条通知在本地通知中心里的已读状态。
* 用于验证"灵动岛点标为已读 → 通知中心也变成已读、未读数下降"。
*/
export async function islandReadState(notificationId: string): Promise<{
found: boolean;
read: boolean;
unreadCount: number;
title: string;
}> {
const { useNotificationStore } = await import('../stores/notifications.js');
const store = useNotificationStore();
if (store.items.length === 0) await store.load().catch(() => undefined);
const target = store.items.find((item) => item.id === notificationId);
return {
found: Boolean(target),
read: Boolean(target?.read),
unreadCount: store.unreadCount,
title: target?.title ?? '',
};
}
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -237,6 +340,9 @@ export function registerSmokeHooks(): void {
offlineScenario,
layoutNavigationSelfTest,
onlineScenario,
islandRealtimeScenario,
islandRealtimeCleanup,
islandReadState,
sessionCleanup,
};
}
@@ -1,7 +1,9 @@
import {
SOCKET_EVENTS,
formatDate,
resolveClassStatus,
type ClassPeriod,
type HomeworkDto,
type NotificationDto,
type ScheduleDto,
} from '@classhelper/shared';
@@ -9,6 +11,7 @@ import { scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
/**
@@ -82,11 +85,68 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
createdAt: notification.createdAt,
courseName: currentPeriod?.courseName ?? null,
teacherName: notification.creator?.name ?? null,
kind: 'notification',
},
context: getIslandClassContext(),
});
}
/** 新作业上岛(homework:new):胶囊提示"新作业",展开看截止时间 */
export function pushHomeworkToIsland(homework: HomeworkDto): void {
const due = homework.dueAt ? formatDate(homework.dueAt, true) : '未设置截止时间';
window.desktop?.islandPush({
notification: {
id: `homework-${homework.id}`,
title: homework.title,
content: homework.content?.trim() || '(老师没有填写作业说明)',
priority: 'NORMAL',
createdAt: homework.createdAt,
courseName: homework.course?.name ?? null,
teacherName: homework.creator?.name ?? null,
kind: 'homework',
subtitle: `截止时间:${due}`,
},
context: getIslandClassContext(),
});
}
/** "叫人"上岛:无论是否上课都立即展开,展示"请 XXX 同学找 XXX 老师" */
export function pushCallToIsland(call: NotificationDto): void {
window.desktop?.islandPush({
notification: {
id: call.id,
title: call.title,
content: call.content?.trim() || '老师正在等你,请尽快前往。',
priority: call.priority,
createdAt: call.createdAt,
courseName: null,
teacherName: call.creator?.name ?? null,
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
},
context: getIslandClassContext(),
});
}
/**
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
* 由 App.vue 在挂载时订阅一次。
*/
export function subscribeIslandMarkRead(): void {
window.desktop?.onIslandMarkRead?.((id) => {
void (async () => {
const store = useNotificationStore();
try {
await store.markRead(id);
} catch {
// 离线或通知已被删除:本地先记为已读,等下次同步纠正
const target = store.items.find((item) => item.id === id);
if (target) target.read = true;
}
})();
});
}
export function startIslandBridge(): void {
if (tickTimer) return;
@@ -97,10 +97,12 @@ onUnmounted(() => {
<template>
<el-container class="layout">
<el-aside width="190px" class="aside">
<el-aside width="200px" class="aside ch-nav">
<div class="brand">
<el-icon :size="20"><School /></el-icon>
<span>班级小助手</span>
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
@@ -170,11 +172,10 @@ onUnmounted(() => {
<style scoped>
.layout {
height: 100vh;
background: var(--ch-bg);
}
.aside {
background: var(--ch-sidebar);
color: #fff;
display: flex;
flex-direction: column;
}
@@ -182,29 +183,38 @@ onUnmounted(() => {
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 54px;
gap: 10px;
height: 52px;
padding: 0 16px;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-size: 14px;
color: var(--ch-text);
}
/* 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%);
}
.brand-text {
letter-spacing: 0.2px;
}
.menu {
flex: 1;
border-right: none;
background: transparent;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #fff;
}
.menu :deep(.el-menu-item) {
margin: 2px 8px;
border-radius: 6px;
}
.menu :deep(.el-menu-item.is-active) {
background: #409eff;
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
--el-menu-text-color: var(--ch-text);
--el-menu-active-color: var(--ch-text);
}
.menu-badge {
@@ -213,22 +223,25 @@ onUnmounted(() => {
.aside-footer {
padding: 10px 16px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid var(--ch-divider);
}
.aside-meta {
font-size: 12px;
color: #8fa3b8;
line-height: 1.8;
color: var(--ch-text-tertiary);
line-height: 1.9;
}
.header {
background: #fff;
height: 52px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--ch-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px;
}
.header-left {
@@ -240,15 +253,22 @@ onUnmounted(() => {
.server-url {
font-size: 12px;
color: #909399;
color: var(--ch-text-tertiary);
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: var(--ch-radius-control);
cursor: pointer;
outline: none;
transition: background 0.15s ease;
}
.user-chip:hover {
background: rgba(0, 0, 0, 0.04);
}
.main {
@@ -11,7 +11,7 @@ import {
type ServerToClientEvents,
} from '@classhelper/shared';
import { socketUrlOf } from '../config.js';
import { pushNotificationToIsland } from '../island/bridge.js';
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
@@ -79,9 +79,24 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
mark(SOCKET_EVENTS.homeworkNew);
ElMessage.success(`新作业:${payload.title}`);
// 作业发布也上岛:胶囊提示"新作业",点击展开看截止时间与要求
pushHomeworkToIsland(payload);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.callNew);
ElMessage({
message: payload.title,
type: 'warning',
duration: 8000,
showClose: true,
});
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
pushCallToIsland(payload);
dispatch(SOCKET_EVENTS.callNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto & { deleted?: boolean }) => {
mark(SOCKET_EVENTS.homeworkUpdated);
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
@@ -1,7 +1,65 @@
/* ============================================================================
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
*
* 参考 ClassIsland(Windows 课件表应用)的观感:
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
* ========================================================================== */
:root {
--ch-bg: #f5f7fa;
--ch-border: #e4e7ed;
--ch-sidebar: #1f2d3d;
/* Fluent 强调色 */
--ch-accent: #0f6cbd;
--ch-accent-hover: #115ea3;
--ch-accent-soft: rgba(15, 108, 189, 0.08);
--ch-accent-soft-strong: rgba(15, 108, 189, 0.14);
/* 层次底色(Mica / Layer) */
--ch-bg: #f3f3f3;
--ch-layer: rgba(255, 255, 255, 0.7);
--ch-layer-solid: #ffffff;
--ch-layer-alt: rgba(249, 249, 249, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.5);
/* 描边(Fluent 用半透明黑,避免生硬的灰线) */
--ch-border: rgba(0, 0, 0, 0.06);
--ch-border-strong: rgba(0, 0, 0, 0.1);
--ch-divider: rgba(0, 0, 0, 0.05);
/* 文本 */
--ch-text: #1b1b1b;
--ch-text-secondary: #5c5c5c;
--ch-text-tertiary: #8a8a8a;
/* 圆角与阴影 */
--ch-radius-card: 8px;
--ch-radius-control: 4px;
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
/* 兼容旧变量名 */
--ch-sidebar: #ffffff;
/* Element Plus 变量对齐 Fluent */
--el-color-primary: #0f6cbd;
--el-color-primary-light-3: #4d94d1;
--el-color-primary-light-5: #7fb1de;
--el-color-primary-light-7: #b3d0ec;
--el-color-primary-light-8: #d0e2f4;
--el-color-primary-light-9: #e7f0f9;
--el-color-primary-dark-2: #115ea3;
--el-border-radius-base: 4px;
--el-border-radius-small: 3px;
--el-text-color-primary: #1b1b1b;
--el-text-color-regular: #3a3a3a;
--el-text-color-secondary: #5c5c5c;
--el-border-color: rgba(0, 0, 0, 0.1);
--el-border-color-light: rgba(0, 0, 0, 0.06);
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
--el-fill-color-blank: transparent;
--el-font-size-base: 13px;
}
* {
@@ -17,11 +75,15 @@ body,
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-family:
'Segoe UI Variable Text', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
background: var(--ch-bg);
color: #303133;
color: var(--ch-text);
/* 桌面端禁用整页选中,更接近原生应用体验 */
user-select: none;
-webkit-font-smoothing: antialiased;
}
input,
@@ -29,6 +91,8 @@ textarea {
user-select: text;
}
/* ---------------------------------------------------------------- 页面骨架 */
.page {
padding: 16px 20px 24px;
}
@@ -39,25 +103,26 @@ textarea {
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
margin-bottom: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
font-size: 20px;
font-weight: 600;
letter-spacing: 0.2px;
}
.page-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
font-size: 12.5px;
color: var(--ch-text-secondary);
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-wrap: wrap;
}
@@ -70,13 +135,161 @@ textarea {
}
.text-muted {
color: #909399;
color: var(--ch-text-tertiary);
}
.offline-banner {
margin: 0 0 12px;
}
/* ---------------------------------------------------------------- 卡片 / 分组 */
/* 亚克力卡片:半透明底 + 极细描边 + 极轻阴影(ClassIsland 的内容卡片观感) */
.el-card {
border-radius: var(--ch-radius-card) !important;
border: 1px solid var(--ch-border) !important;
background: var(--ch-layer) !important;
backdrop-filter: blur(20px) saturate(1.1);
box-shadow: var(--ch-shadow-card) !important;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.el-card:hover {
border-color: var(--ch-border-strong) !important;
}
.el-card__header {
padding: 12px 16px !important;
border-bottom: 1px solid var(--ch-divider) !important;
font-weight: 600;
font-size: 13.5px;
}
.el-card__body {
padding: 14px 16px !important;
}
/* ---------------------------------------------------------------- 控件 */
.el-button {
border-radius: var(--ch-radius-control) !important;
font-weight: 500;
}
.el-button--default {
background: rgba(255, 255, 255, 0.7) !important;
border-color: var(--ch-border-strong) !important;
}
.el-button--default:hover {
background: rgba(0, 0, 0, 0.04) !important;
}
.el-button--primary {
box-shadow: 0 1px 2px rgba(15, 108, 189, 0.2);
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
border-radius: var(--ch-radius-control) !important;
background: rgba(255, 255, 255, 0.7) !important;
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--ch-accent) inset !important;
}
.el-tag {
border-radius: var(--ch-radius-control) !important;
}
.el-dialog,
.el-drawer {
border-radius: var(--ch-radius-card) !important;
background: var(--ch-layer-solid) !important;
}
.el-message-box {
border-radius: var(--ch-radius-card) !important;
}
.el-popper,
.el-dropdown__popper {
border-radius: var(--ch-radius-card) !important;
box-shadow: var(--ch-shadow-flyout) !important;
}
/* ---------------------------------------------------------------- 表格 */
.el-table {
--el-table-border-color: var(--ch-divider);
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
background: transparent !important;
}
.el-table th.el-table__cell {
background: transparent !important;
font-weight: 600;
color: var(--ch-text-secondary);
}
.el-table td.el-table__cell,
.el-table th.el-table__cell {
border-bottom: 1px solid var(--ch-divider) !important;
}
.el-table__inner-wrapper::before {
display: none;
}
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
.ch-nav {
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(24px) saturate(1.2);
border-right: 1px solid var(--ch-border);
}
.ch-nav .el-menu-item {
height: 40px;
line-height: 40px;
margin: 2px 8px;
border-radius: var(--ch-radius-control);
color: var(--ch-text);
font-weight: 500;
position: relative;
}
.ch-nav .el-menu-item:hover {
background: rgba(0, 0, 0, 0.04);
}
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
.ch-nav .el-menu-item.is-active {
background: var(--ch-accent-soft);
color: var(--ch-text);
}
.ch-nav .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--ch-accent);
}
/* ---------------------------------------------------------------- 课表时间轴 */
.timetable-cell {
min-height: 30px;
}
@@ -87,7 +300,7 @@ textarea {
.timetable-meta {
font-size: 12px;
color: #909399;
color: var(--ch-text-tertiary);
}
.unread-dot {
@@ -95,19 +308,27 @@ textarea {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
background: #d13438;
margin-right: 6px;
}
/* 滚动条美化(Windows 默认滚动条较粗) */
/* ---------------------------------------------------------------- 滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: #c8ccd4;
border-radius: 4px;
background: rgba(0, 0, 0, 0.18);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
background-clip: content-box;
}
::-webkit-scrollbar-track {