diff --git a/docs/dev/20261017-implementation.md b/docs/dev/20261017-implementation.md index 5f33517..6ca6beb 100644 --- a/docs/dev/20261017-implementation.md +++ b/docs/dev/20261017-implementation.md @@ -148,6 +148,13 @@ Windows 触摸模式使用 Electron setShape:动画开始放开绘制区域, 外壳使用经典岛同源的 SpringValue 与 squirclePath;宽高和圆角每帧同时推进,保留零过冲钳制、动画速度与后台吸附兜底。 原生画布固定,隐藏渲染器不运行旧形变;统一心跳不随布局切换重复注册。 +内容过渡与外壳采用同参数弹簧同步推进:常态透明度为 max(0, 1-1.5p),展开透明度为 p²,收起时反向播放。 +不再使用独立的 200ms 内容淡入,避免内容已经切完、外壳还在展开的割裂;等尺寸页面也有完整过渡。 +动画开关和速度同时作用于外壳、内容与铃铛提示,快速反向时保留当前进度;后台窗口仍直接落位。 +视觉验收逐帧断言展开与收起都有多帧尺寸变化和中间透明度,可用 ISLAND_MOTION_CAPTURE=1 留档真实合成帧。 +本机 Electron 实测 prefers-reduced-motion=true;合并版自定义岛额外检查此系统偏好,造成应用内开着动画仍直接吸附。 +现统一以应用内动画开关为准,与经典岛一致;不再由第二套系统判据覆盖已开启的动画。 + 课表更新链路做了以下修正: - 课表变更事件立即取 active-week,学期接口在旁路校正,避免慢学期请求串行挡住新课表。 @@ -158,3 +165,23 @@ Windows 触摸模式使用 Electron setShape:动画开始放开绘制区域, 验证使用隔离配置、本地消息和替换的本地 API,覆盖慢学期接口、变更事件、重连刷新、迟到请求隔离、课表 IPC 即时更新及窗口重新加载恢复。 桌面视觉回归覆盖经典岛原有状态,以及自定义岛单通知、多通知、常态、空闲展开、窄屏、长文本、触摸和固定原生窗口动画。 + +## ClassIsland 课前与上下课提醒(后续最新要求) + +现场重新读取了上游 master 的以下实现: + +- [ClassNotificationProvider](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Services/NotificationProviders/ClassNotificationProvider.cs):准备上课、上课、下课的独立提醒链。 +- [ClassNotificationSettings](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Models/NotificationProviderSettings/ClassNotificationSettings.cs):室内课程默认提前 60 秒。 +- [ClassNotificationProviderControl](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Controls/NotificationProviders/ClassNotificationProviderControl.axaml):倒计时、下节科目及右侧起止时间。 +- [FluentTheme](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/XamlThemes/FluentTheme/Styles.axaml) 与 [SlantedMaskControl](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Controls/SlantedMaskControl.cs):40px 横条、五段斜切遮罩、360ms 区域动画与 120ms 错峰、标题延迟 260ms 淡入和缩放。 + +自定义岛的常态外观按上述 ClassIsland 横条恢复半透明深色底、1px 描边、8px 默认圆角和 14/16/18/20 四档字体,内容居中。 +通知继续共用经典操作组件,使用同一半透明岛壳。课程组件按「课程名 起始时间–结束时间」横向排列,时间位于名称右侧,开关文案改为「显示课程时间」。 + +LessonReminderTracker 只读取服务端已解析的当天 ScheduleDto 投影,不参与 ClassPlan 在线启用与轮换选择。 +课前 60 秒开始:3 秒强调遮罩后显示实时秒数和课程时间,持续到上课;上课遮罩 5 秒后退出。 +下课先提示 2 秒,再预告下节课程 10 秒;当天最后一课下课只提示 5 秒。不补弹已经过去的上下课事件。 +刷新同一课表不会重置遮罩,临时换课立即更新名称,移除课程或退出账号会取消提醒。 +提醒是独立本机状态,不写入教师通知、未读计数或通知队列;已经打开的通知详情和紧急提示优先展示。 +经典与自定义两种布局使用同一个 LessonReminderContent;原生窗口仍为固定画布,自定义岛用自然尺寸缩放适配。 +本次只移植课表可见提醒和动画,不自动启用语音与音效。 diff --git a/docs/reference.md b/docs/reference.md index a7ebd82..6ddc738 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1583,19 +1583,23 @@ makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列 天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。 自定义组件栏位于灵动岛内部,提醒内容也在同一个岛体内展开,不再嵌套另一张带底色的岛卡片。 -可在「设置 → 灵动岛」选择常驻岛:收起内容与无通知展开内容分别编辑,并随方案一起导入导出。 -常驻模式的新消息(含紧急消息)先亮铃铛和数量,点击岛体或铃铛查看通知列表;上课时也可主动查看。 -没有待处理消息时点击岛体,展示自定义的空闲展开页;处理完消息后回到常驻内容。关闭常驻后使用原提醒规则。 +可在「设置 → 灵动岛」选择经典或自定义灵动岛,常驻内容已并入自定义模式,不再有独立开关。 +常态内容与无通知展开内容分别编辑,并随方案一起导入导出。紧急通知立即展开,课中普通通知暂存,下课后展开;点击铃铛可主动查看。 +没有待处理消息时点击岛体,展示自定义的空闲展开页;处理完消息后回到常态内容。通知正文、排序与已读操作共用经典岛组件。 铃铛在右侧预留固定空间,收到消息时内容不横移;默认组件按垂直中心对齐,时间使用等宽数字。 常驻外观重新按 ClassIsland 源码对齐:单行基准高 40 px、左右留白 12 px、四档字号 14/16/18/20、时钟使用强调字号 18 px;默认缩放 1×、圆角 8 px、背景不透明度 0.5、行间距 5 px。 窗口设置沿用「指针移入淡化」与「反转指针移入淡化」;普通背景淡化至 5% 并穿透点击,移出恢复。铃铛/展开按钮、编辑模式、已经展开的内容及触摸操作短暂宽限期保持可交互。 界面缩放范围 0.75–2.5×,圆角 0–20 px,背景不透明度 0–1;整体外观可一键恢复 ClassIsland 默认值。 -常驻岛独立使用 ClassIsland 经典主题的 550ms 背景尺寸曲线与 200ms 内容淡入;旧胶囊弹簧在常驻模式停止运行。 +自定义岛使用 ClassIsland 半透明横条外观,内容与外壳同步过渡;应用内动画开关直接决定是否播放,系统减少动态效果不再额外关闭已开启的动画。 开合只改变岛壳,原生窗口保持固定画布,避免窗口重排、闪烁和两端尺寸防抖互相追赶。隐藏窗口直接落到目标尺寸。 布局按内容自然尺寸、屏幕可用空间和缩放设置自动适配;长文本限制宽度并省略,同一行的时间、分隔线与文字保持成组对齐,左右及底部停靠保持对应锚点。 通知列表和空闲展开页均不出现滚动条;较多消息按可用高度展示,完整长正文及剩余消息可通过「查看完整内容」进入应用查看,底部操作始终保留。 编辑器左侧按用途添加组件、中央预览岛内排列、右侧只显示当前对象的设置;方案操作收在顶部菜单。 -主设置页选择经典或自定义灵动岛,自定义模式的组件尺寸、配色在编辑器中调整,主设置页保留位置与提醒选项。 +课程时间位于课程名称右侧并居中对齐,显示完整的起止时间;可在组件设置中关闭「显示课程时间」。 +经典与自定义两种布局均在上课前 60 秒弹出准备提醒:先播放 ClassIsland 五段斜切强调遮罩,再显示秒级倒计时、课程及右侧时间范围。 +上课时提示 5 秒;下课时先提示,再预告下一节课程。最后一节课结束后只提示课间状态,不展示不存在的下一节课程。 +课表提醒独立于通知队列,刷新同一课表不重复开始,移除课程自动取消;已经打开的通知详情与紧急消息优先。提醒只在本机展示,不自动启用音效和语音。 +主设置页保留字号、位置、透明度、动画与提醒选项,自定义布局和组件内容在编辑器中调整。 迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课, 迁移选择同一时段 ID 最小的行,全部原行仍保留;旧数据未记录的群组、临时层和时间点名称无法恢复。 diff --git a/packages/desktop-client/src/island/ComponentInspector.vue b/packages/desktop-client/src/island/ComponentInspector.vue index 36eb17b..94049d3 100644 --- a/packages/desktop-client/src/island/ComponentInspector.vue +++ b/packages/desktop-client/src/island/ComponentInspector.vue @@ -92,7 +92,7 @@ const fonts = { - 显示节次 + 显示课程时间 diff --git a/packages/desktop-client/src/island/ComponentNode.vue b/packages/desktop-client/src/island/ComponentNode.vue index 5ab4305..19040a7 100644 --- a/packages/desktop-client/src/island/ComponentNode.vue +++ b/packages/desktop-client/src/island/ComponentNode.vue @@ -163,9 +163,9 @@ const style = computed(() => { │ - - 第 {{ i + 1 }} 节 · {{ s.startTime }} + {{ s.course?.name }} + {{ s.startTime }}–{{ s.endTime }} 暂无课程 @@ -199,8 +199,21 @@ const style = computed(() => { } .schedule > div { display: flex; - flex-direction: column; - gap: 3px; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; +} +.lesson-line strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lesson-time { + flex-shrink: 0; + font-variant-numeric: tabular-nums; + white-space: nowrap; } .schedule > div + div { margin-top: 8px; diff --git a/packages/desktop-client/src/island/CustomIsland.vue b/packages/desktop-client/src/island/CustomIsland.vue index d965f95..044979b 100644 --- a/packages/desktop-client/src/island/CustomIsland.vue +++ b/packages/desktop-client/src/island/CustomIsland.vue @@ -150,14 +150,9 @@ const shellStyle = computed(() => ({ maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`, maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`, color: '#ffffffeb', - fontSize: `${props.appearance.fontSize}px`, - '--custom-color': - props.appearance.style === 'glass' - ? '#202024cd' - : props.appearance.style === 'tinted' - ? '#0b0b0f' - : '#000000', - '--custom-radius': `${props.appearance.radius}px`, + fontSize: `${(props.appearance.fontSize / 13) * CI.bodyFontSize}px`, + '--custom-color': '#20202080', + '--custom-radius': `${scheme.value.radius}px`, '--custom-row-gap': `${scheme.value.spacing}px`, ...(draft.value.residentEnabled ? { diff --git a/packages/desktop-client/src/island/CustomIslandRuntime.vue b/packages/desktop-client/src/island/CustomIslandRuntime.vue index 4f8ce90..9b0557f 100644 --- a/packages/desktop-client/src/island/CustomIslandRuntime.vue +++ b/packages/desktop-client/src/island/CustomIslandRuntime.vue @@ -7,12 +7,14 @@ import { type IslandLayoutSettings, type IslandState, type ScheduleDto, + type IslandLessonReminder, } from '@classhelper/shared'; import ComponentNode from './ComponentNode.vue'; import ComponentIcon from './ComponentIcon.vue'; import ClassicNotifications from './ClassicNotifications.vue'; import { squirclePath } from './squircle.js'; import { SpringValue } from './spring.js'; +import LessonReminderContent from './LessonReminderContent.vue'; const props = defineProps<{ settings: IslandLayoutSettings; @@ -23,6 +25,15 @@ const props = defineProps<{ }>(); const scheme = computed(() => props.settings.schemes.find((s) => s.id === props.settings.activeSchemeId)!); const expanded = computed(() => props.state.mode === 'expanded'); +const lesson = computed(() => props.state.lessonReminder ?? null); +const lastLesson = ref(null); +watch( + lesson, + (value) => { + if (value) lastLesson.value = value; + }, + { immediate: true }, +); const messages = computed(() => { const unique = new Map( [props.state.active, ...props.state.queued].filter((n) => n !== null).map((n) => [n.id, n]), @@ -36,11 +47,16 @@ const messages = computed(() => { const shell = ref(); const compact = ref(); const details = ref(); +const lessonSource = ref(); const now = ref(Date.now()); const visible = ref(!document.hidden); const motion = computed(() => props.appearance.animations && visible.value && props.nativeVisible); const available = ref({ width: 940, height: 620 }); -const natural = ref({ compact: { width: 40, height: 40 }, details: { width: 280, height: 80 } }); +const natural = ref({ + compact: { width: 40, height: 40 }, + details: { width: 280, height: 80 }, + lesson: { width: 480, height: 40 }, +}); const anchor = computed(() => props.appearance.position.endsWith('left') ? 'left' @@ -59,9 +75,24 @@ function fitted(size: { width: number; height: number }) { } const compactFit = computed(() => fitted(natural.value.compact)); const detailsFit = computed(() => fitted(natural.value.details)); -const fit = computed(() => (expanded.value ? detailsFit.value : compactFit.value)); +const lessonFit = computed(() => fitted(natural.value.lesson)); +const fit = computed(() => + lesson.value ? lessonFit.value : expanded.value ? detailsFit.value : compactFit.value, +); const morph = ref({ width: 40, height: 40, radius: 20 }); -const springs = { width: new SpringValue(40), height: new SpringValue(40), radius: new SpringValue(20) }; +const springs = { + width: new SpringValue(40), + height: new SpringValue(40), + radius: new SpringValue(20), + content: new SpringValue(0, { snapThreshold: 0.001 }), + lesson: new SpringValue(0, { snapThreshold: 0.001 }), +}; +// 与经典岛一致:常态内容先退净,展开内容按进度平方进入;反向收起沿同一条曲线返回。 +// 独立进度也保证两页尺寸相同时仍播放过渡,不能靠高度差推导。 +const progress = ref(0); +const lessonProgress = ref(0); +const compactAlpha = computed(() => Math.max(0, 1 - progress.value * 1.5) * (1 - lessonProgress.value)); +const detailsAlpha = computed(() => progress.value * progress.value * (1 - lessonProgress.value)); const path = computed(() => squirclePath(morph.value.width, morph.value.height, morph.value.radius)); const style = computed(() => ({ width: `${morph.value.width}px`, @@ -80,9 +111,10 @@ const style = computed(() => ({ '--resident-scale': fit.value.scale, '--custom-row-gap': `${scheme.value.spacing}px`, '--resident-row-width': `${Math.max(60, available.value.width / scheme.value.scale - 58)}px`, - '--secondary': `${props.appearance.fontSize * 0.78}px`, - '--emphasized': `${props.appearance.fontSize * 1.1}px`, - '--large': `${props.appearance.fontSize * 1.3}px`, + '--secondary': `${(props.appearance.fontSize / 13) * 14}px`, + '--emphasized': `${(props.appearance.fontSize / 13) * 18}px`, + '--large': `${(props.appearance.fontSize / 13) * 20}px`, + '--ci-body-font': `${(props.appearance.fontSize / 13) * 16}px`, '--resident-fade-duration': motion.value ? `${200 / props.appearance.speed}ms` : '0ms', })); let morphFrame = 0; @@ -90,7 +122,7 @@ let morphFallback: ReturnType | undefined; function animate() { const size = fit.value; const radius = Math.min( - expanded.value ? props.appearance.radius * 2.4 : props.appearance.radius, + lesson.value ? 8 : expanded.value ? props.appearance.radius : scheme.value.radius, size.width / 2, size.height / 2, ); @@ -100,14 +132,21 @@ function animate() { morphFrame = 0; clearTimeout(morphFallback); for (const key of ['width', 'height', 'radius'] as const) springs[key].snap(target[key]); + springs.content.snap(expanded.value ? 1 : 0); + springs.lesson.snap(lesson.value ? 1 : 0); + progress.value = springs.content.value; + lessonProgress.value = springs.lesson.value; morph.value = target; void nextTick(reportHit); }; - if (!motion.value || matchMedia('(prefers-reduced-motion: reduce)').matches) { + // 应用内动画开关与经典岛保持一致;系统减少动态效果不能再次覆盖用户已开启的动画。 + if (!motion.value) { snap(); return; } for (const key of ['width', 'height', 'radius'] as const) springs[key].setTarget(target[key]); + springs.content.setTarget(expanded.value ? 1 : 0); + springs.lesson.setTarget(lesson.value ? 1 : 0); clearTimeout(morphFallback); morphFallback = setTimeout(snap, 1200 / props.appearance.speed); if (morphFrame) return; @@ -120,6 +159,8 @@ function animate() { .map((s) => s.step(dt)) .some(Boolean); morph.value = { width: springs.width.value, height: springs.height.value, radius: springs.radius.value }; + progress.value = springs.content.value; + lessonProgress.value = springs.lesson.value; if (moving) morphFrame = requestAnimationFrame(frame); else { morphFrame = 0; @@ -200,7 +241,11 @@ function measure() { width: Math.max(40, element.offsetWidth), height: Math.max(40, element.offsetHeight), }); - natural.value = { compact: read(compact.value), details: read(details.value) }; + natural.value = { + compact: read(compact.value), + details: read(details.value), + lesson: lessonSource.value ? read(lessonSource.value) : natural.value.lesson, + }; void nextTick(() => { window.island?.reportSize({ width: fit.value.width, height: fit.value.height }); trackHit(); @@ -221,9 +266,12 @@ function visibility() { void nextTick(reportHit); } // 不深度监听整份状态;只在目标内容/几何变化时测量,时钟每秒只改文本。 -watch([expanded, scheme, () => props.state.listExpanded, messages], measure, { deep: true, flush: 'post' }); +watch([expanded, scheme, lesson, () => props.state.listExpanded, messages], measure, { + deep: true, + flush: 'post', +}); watch( - [fit, motion, () => props.appearance.radius, () => props.appearance.speed], + [fit, motion, expanded, lesson, () => props.appearance.radius, () => props.appearance.speed], () => { animate(); trackHit(); @@ -240,6 +288,7 @@ onMounted(() => { observer = new ResizeObserver(measure); if (compact.value) observer.observe(compact.value); if (details.value) observer.observe(details.value); + if (lessonSource.value) observer.observe(lessonSource.value); clock = setInterval(() => (now.value = Date.now()), 1000); window.addEventListener('resize', resize); document.addEventListener('visibilitychange', visibility); @@ -276,7 +325,8 @@ onUnmounted(() => { pill: !expanded, urgent: state.active?.priority === 'URGENT', call: state.active?.kind === 'call', - 'resident-separated': scheme.separated && !expanded, + 'resident-separated': scheme.separated && !expanded && !lesson, + 'resident-lesson': !!lesson, }" :style="style" :role="expanded ? 'region' : 'button'" @@ -306,7 +356,11 @@ onUnmounted(() => { { { + + + + + diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index 1806ccf..fd4d232 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -1,6 +1,6 @@ + + + + + + 距上课还剩 + {{ leftSeconds }} + 秒 + + 课间休息 + + 下节课是: + {{ course }} + {{ time }} + + + + + + + + + + + {{ title }} + + + + + + + + + diff --git a/packages/desktop-client/src/island/classic-island.css b/packages/desktop-client/src/island/classic-island.css index 7b8c52e..c6bb205 100644 --- a/packages/desktop-client/src/island/classic-island.css +++ b/packages/desktop-client/src/island/classic-island.css @@ -84,6 +84,16 @@ body { .island-card.hidden-placeholder { visibility: hidden; } +.island-card.ci-lesson-card .shape path { + fill: transparent; + stroke: none; +} +.island-card .ci-fixed-lesson { + position: absolute; + inset: 0; + width: 100%; + min-width: 0; +} /* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */ .island-card .shape { diff --git a/packages/desktop-client/src/island/custom-runtime.css b/packages/desktop-client/src/island/custom-runtime.css index 9d8de65..891e048 100644 --- a/packages/desktop-client/src/island/custom-runtime.css +++ b/packages/desktop-client/src/island/custom-runtime.css @@ -44,18 +44,10 @@ left: 50%; transform: translateX(-50%); width: max-content; - opacity: 0; pointer-events: none; - visibility: hidden; - transition: - opacity var(--resident-fade-duration) cubic-bezier(0.47, 0, 0.745, 0.715), - visibility 0s var(--resident-fade-duration); } .resident-card .resident-page.page-active { - opacity: 1; pointer-events: auto; - visibility: visible; - transition-delay: 0s; } .resident-card .resident-source { width: max-content; @@ -83,6 +75,7 @@ gap: 6px; padding: 0 12px; min-height: max(40px, calc(var(--island-font) * 2.5)); + font-size: var(--ci-body-font, 16px); } .resident-window .resident-card.resident-separated { background: transparent; @@ -152,7 +145,7 @@ color: #ffffff80; } .resident-card .resident-bell span { - animation: resident-indicator-in 250ms cubic-bezier(0.25, 1, 0.5, 1); + animation: resident-indicator-in var(--resident-fade-duration) cubic-bezier(0.25, 1, 0.5, 1); } .resident-card .resident-details { min-width: 160px; @@ -183,6 +176,7 @@ display: block; } .resident-card .resident-expanded-rows { + font-size: var(--ci-body-font, 16px); border-top: 1px solid #ffffff1a; padding-top: 8px; } @@ -202,15 +196,6 @@ filter: brightness(1.18); } } -@media (prefers-reduced-motion: reduce) { - .resident-window .resident-card, - .resident-card .resident-page { - transition-duration: 0ms; - } - .resident-card .resident-bell span { - animation: none; - } -} @keyframes resident-indicator-in { from { opacity: 0; @@ -253,3 +238,15 @@ .resident-card.resident-separated > .tint { visibility: hidden; } +/* ClassIsland Fluent MainWindowLine:半透明底、1px 边框,强调遮罩只在提醒时播放。 */ +.resident-window .resident-card { + --wn-bg: #20202080; + --wn-border: #ffffff26; +} +.resident-window .resident-card > .tint { + display: none; +} +.resident-card.resident-lesson .shape path { + fill: transparent; + stroke: none; +} diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 5dc8476..c5d496d 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -18,9 +18,11 @@ import { type IslandMode, type IslandNotification, type IslandState, + type IslandLessonReminder, } from '@classhelper/shared'; import { logger } from './logger.js'; import { getConfig, saveConfig } from './config.js'; +import { LessonReminderTracker } from './lesson-reminder.js'; /** 生产环境的灵动岛页面地址(导航白名单的精确比对用) */ const prodIslandUrl = pathToFileURL(path.join(__dirname, '../renderer/island.html')).href; @@ -158,6 +160,37 @@ interface IslandActionPayload { } class IslandController { + private lessonTracker = new LessonReminderTracker(); + private lessonCue: IslandLessonReminder | null = null; + private lessonTimer: ReturnType | null = null; + private lessonTestTime: number | null = null; + private get lessonShowing(): boolean { + return Boolean( + this.lessonCue && + !this.editingLayout && + !this.previewId && + !( + this.state.active && + (this.state.mode === 'expanded' || + (this.state.active.priority === 'URGENT' && this.state.mode !== 'hidden')) + ), + ); + } + /** 冒烟只在隔离配置中推进时钟,不修改操作系统时间。 */ + setLessonReminderTestTime(at: number | null): void { + if (process.env.ELECTRON_SMOKE_TEST !== '1') return; + this.lessonTestTime = at; + this.refreshLessonReminder(); + } + private refreshLessonReminder(): void { + const next = this.lessonTracker.update(this.schedules, new Date(this.lessonTestTime ?? Date.now())); + if (JSON.stringify(next) === JSON.stringify(this.lessonCue)) return; + this.lessonCue = next; + // 触摸窗口先放得下新横条,再下发内容;混合宽高变化也只能增大画布。 + if (this.lessonShowing) this.syncTouchLayout(); + this.emit(); + this.syncWindow(); + } private win: BrowserWindow | null = null; private rendererUrl: string | null = null; private ready = false; @@ -582,6 +615,7 @@ class IslandController { await win.loadFile(path.join(__dirname, '../renderer/island.html')); } this.ready = true; + if (!this.lessonTimer) this.lessonTimer = setInterval(() => this.refreshLessonReminder(), 1000); if (this.pendingState) { this.emit(this.pendingState); this.pendingState = null; @@ -607,7 +641,11 @@ class IslandController { } getState(): IslandState { - return { ...this.state, queued: [...this.state.queued] }; + return { + ...this.state, + lessonReminder: this.lessonShowing ? this.lessonCue : null, + queued: [...this.state.queued], + }; } isReady(): boolean { @@ -805,6 +843,7 @@ class IslandController { setClassState(payload: IslandClassStatePayload): void { if (Array.isArray(payload.schedules)) { this.schedules = payload.schedules.slice(0, 100); + this.refreshLessonReminder(); if (this.ready && this.win && !this.win.isDestroyed()) this.win.webContents.send('island:schedules', this.schedules); } @@ -1116,6 +1155,10 @@ class IslandController { } destroy(): void { + if (this.lessonTimer) clearInterval(this.lessonTimer); + this.lessonTimer = null; + this.lessonCue = null; + this.lessonTracker = new LessonReminderTracker(); if (this.residentShapeTimer) clearTimeout(this.residentShapeTimer); this.residentShapeTimer = null; this.clearTimers(); @@ -1362,6 +1405,16 @@ class IslandController { return; } + if (this.lessonShowing) { + this.cancelFade(); + if (!this.win.isVisible()) this.applyWindowLayout(); + this.win.setOpacity(this.appearance.opacity); + if (!(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual')) + this.win.showInactive(); + this.syncHitFromCursor(); + return; + } + // 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知 / 叫人"允许出现在屏幕上) // 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算 // (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏) @@ -1435,6 +1488,13 @@ class IslandController { height: this.customSize.height + 2 * ISLAND_SHADOW_PAD, }; const size = this.formSize(); + if (this.touchMode && this.lessonShowing) { + const current = this.win?.isVisible() ? this.win.getBounds() : null; + return { + width: Math.round(Math.max(size.width + 2 * ISLAND_SHADOW_PAD, current?.width ?? 0)), + height: Math.round(Math.max(size.height + 2 * ISLAND_SHADOW_PAD, current?.height ?? 0)), + }; + } if (!this.touchMode) { return { width: Math.round(Math.max(BBOX_SIZE.width, size.width + ISLAND_SHADOW_PAD * 2)), @@ -1760,6 +1820,7 @@ class IslandController { this.residentEnabled && settings.pointerFadeEnabled && !this.editingLayout && + !this.lessonShowing && Date.now() >= this.residentTouchUntil && Date.now() >= this.residentMotionUntil && this.state.mode !== 'expanded' && @@ -1848,6 +1909,7 @@ class IslandController { /** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */ private formSize(): { width: number; height: number } { if (this.customLayout) return this.customSize; + if (this.lessonShowing) return { width: SIZES.expanded.width, height: 40 }; // 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数) const layout = this.listLayout(); if (layout) return { width: SIZES.expanded.width, height: layout.height }; @@ -1921,6 +1983,10 @@ class IslandController { this.setInteractive(false, '窗口不可见'); return; } + if (this.lessonShowing) { + this.setInteractive(false, '课表提醒不遮挡桌面操作'); + return; + } // 完全隐藏(含淡出过程中):一律不再接收鼠标,避免窗口在消失的几百毫秒里吞掉桌面点击。 // 空闲细缝态(idleSliver)是"可见即可点",不走这条。 if (this.state.mode === 'hidden' && !this.appearance.idleSliver && !this.customLayout) { diff --git a/packages/desktop-client/src/main/lesson-reminder.ts b/packages/desktop-client/src/main/lesson-reminder.ts new file mode 100644 index 0000000..560a549 --- /dev/null +++ b/packages/desktop-client/src/main/lesson-reminder.ts @@ -0,0 +1,75 @@ +import { + dayKeyLocal, + resolveClassStatus, + type IslandLessonReminder, + type ScheduleDto, +} from '@classhelper/shared'; + +/** + * 来源:ClassIsland/Services/NotificationProviders/ClassNotificationProvider.cs。 + * 室内课提前 60 秒,准备遮罩 3 秒,上课 5 秒,下课遮罩 2 秒后预告下节课 10 秒。 + * 只消费服务端已经解析的日课表投影;不在这里重写 ClassPlan 启用或轮换规则。 + */ +export class LessonReminderTracker { + private current: IslandLessonReminder | null = null; + + update(schedules: ScheduleDto[], now: Date): IslandLessonReminder | null { + const at = now.getTime(); + const keyOf = (s: ScheduleDto, kind: IslandLessonReminder['kind']) => + `${dayKeyLocal(now)}:${s.id}:${s.startTime}:${s.endTime}:${kind}`; + const status = resolveClassStatus(schedules, now); + const timeAt = (time: string) => { + const d = new Date(now); + const [h, m] = time.split(':').map(Number); + d.setHours(h!, m!, 0, 0); + return d.getTime(); + }; + const today = schedules.filter((s) => s.dayOfWeek === (now.getDay() || 7)); + const current = today.find((s) => s.id === status.current?.scheduleId); + const next = today.find((s) => s.id === status.next?.scheduleId); + const ended = today + .filter((s) => at >= timeAt(s.endTime) && at - timeAt(s.endTime) < 12_000) + .sort((a, b) => b.endTime.localeCompare(a.endTime))[0]; + let kind: IslandLessonReminder['kind'] | null = null; + let lesson: ScheduleDto | undefined; + if (current && at - timeAt(current.startTime) < 5_000) { + kind = 'on-class'; + lesson = current; + } else if ( + !current && + ended && + at - timeAt(ended.endTime) < (next ? 12_000 : 5_000) && + (at - timeAt(ended.endTime) < 5_000 || this.current?.id === keyOf(ended, 'break')) + ) { + kind = 'break'; + lesson = ended; + } else if (!current && next && timeAt(next.startTime) - at <= 60_000) { + kind = 'prepare'; + lesson = next; + } + if (!lesson || !kind) return (this.current = null); + const id = keyOf(lesson, kind); + const entered = this.current?.id === id && at >= this.current.startedAt ? this.current.startedAt : at; + const start = timeAt(lesson.startTime); + const end = timeAt(lesson.endTime); + return (this.current = { + id, + kind, + courseName: lesson.course?.name || '课程', + startTime: lesson.startTime, + endTime: lesson.endTime, + startedAt: entered, + maskUntil: + kind === 'prepare' + ? Math.min(start, entered + 3000) + : kind === 'break' && next + ? entered + 2000 + : entered + 5000, + endsAt: kind === 'prepare' ? start : kind === 'on-class' ? start + 5000 : end + (next ? 12_000 : 5000), + lessonStartsAt: start, + nextCourseName: kind === 'break' ? next?.course?.name : undefined, + nextStartTime: kind === 'break' ? next?.startTime : undefined, + nextEndTime: kind === 'break' ? next?.endTime : undefined, + }); + } +} diff --git a/packages/desktop-client/src/main/smoke-resident.ts b/packages/desktop-client/src/main/smoke-resident.ts index 4dcd0f2..08f97ce 100644 --- a/packages/desktop-client/src/main/smoke-resident.ts +++ b/packages/desktop-client/src/main/smoke-resident.ts @@ -5,9 +5,11 @@ import { islandNode, normalizeIslandLayouts, ISLAND_SHADOW_PAD, + type ScheduleDto, } from '@classhelper/shared'; import { getConfig, saveConfig } from './config.js'; import { island } from './island.js'; +import { LessonReminderTracker } from './lesson-reminder.js'; /** 常驻岛始终用隔离的冒烟配置和本地消息;不读取或标记真实班级通知。 */ export async function verifyResidentSmoke( @@ -52,6 +54,71 @@ export async function verifyResidentSmoke( const expandedText = islandNode('text', 'resident-expanded-text'); expandedText.settings.text = '今日安排'; const scheme = fixture.schemes[0]!; + const dateAt = (h: number, m: number, s = 0) => { + const date = new Date(); + date.setHours(h, m, s, 0); + return date; + }; + const lessonFixtures: ScheduleDto[] = [ + { + id: 'lesson-math', + classId: 'local', + courseId: 'math', + course: { id: 'math', name: '数学' }, + dayOfWeek: new Date().getDay() || 7, + startTime: '09:00', + endTime: '09:45', + location: null, + weekStart: 1, + weekEnd: 40, + weekParity: 'ALL', + createdAt: new Date().toISOString(), + }, + { + id: 'lesson-chinese', + classId: 'local', + courseId: 'chinese', + course: { id: 'chinese', name: '语文' }, + dayOfWeek: new Date().getDay() || 7, + startTime: '09:55', + endTime: '10:40', + location: null, + weekStart: 1, + weekEnd: 40, + weekParity: 'ALL', + createdAt: new Date().toISOString(), + }, + ]; + await check('课前60秒、上课与下课时间边界正确,刷新和改课不重复重置提醒', () => { + const tracker = new LessonReminderTracker(); + if (tracker.update(lessonFixtures, dateAt(8, 58, 59))) return false; + const prepare = tracker.update(lessonFixtures, dateAt(8, 59)); + const repeat = tracker.update(lessonFixtures, dateAt(8, 59, 5)); + const renamed = lessonFixtures.map((s) => ({ ...s, course: { ...s.course!, name: '临时换课' } })); + const changed = tracker.update(renamed, dateAt(8, 59, 6)); + const onClass = tracker.update(lessonFixtures, dateAt(9, 0)); + const noLate = tracker.update(lessonFixtures, dateAt(9, 0, 6)); + const breaking = tracker.update(lessonFixtures, dateAt(9, 45)); + const after = tracker.update(lessonFixtures, dateAt(9, 45, 13)); + const lastBreak = tracker.update(lessonFixtures, dateAt(10, 40)); + const end = tracker.update(lessonFixtures, dateAt(10, 40, 6)); + const removed = tracker.update([], dateAt(8, 59)); + return ( + prepare?.kind === 'prepare' && + prepare.maskUntil - prepare.startedAt === 3000 && + repeat?.startedAt === prepare.startedAt && + changed?.courseName === '临时换课' && + changed.maskUntil === prepare.maskUntil && + onClass?.kind === 'on-class' && + !noLate && + breaking?.kind === 'break' && + breaking.nextCourseName === '语文' && + !after && + lastBreak?.kind === 'break' && + !end && + !removed + ); + }); scheme.rows[0]!.nodes = [ islandNode('clock', 'resident-clock'), islandNode('separator', 'resident-separator'), @@ -299,6 +366,10 @@ export async function verifyResidentSmoke( return false; }; const immediate = (await waitForCourse()) && Date.now() - started < 600; + const aligned = await win.webContents.executeJavaScript(`(() => { + const name=document.querySelector('.resident-compact .schedule strong')?.getBoundingClientRect(), time=document.querySelector('.resident-compact .lesson-time')?.getBoundingClientRect(); + return Boolean(name&&time&&time.left>=name.right&&Math.abs(time.top+time.height/2-name.top-name.height/2)<=2); + })()`); await win.webContents.reload(); await pause(800); const restored = await waitForCourse(); @@ -307,28 +378,43 @@ export async function verifyResidentSmoke( island.syncCustomLayout(); await pause(800); island.setClassState({ inClass: true, currentPeriodEnd: null, week: 1, schedules: [] }); - return immediate && restored && snapshot[0]?.course?.name === '实时课程'; + return immediate && aligned && restored && snapshot[0]?.course?.name === '实时课程'; }); await check('常驻岛开合复用固定画布,动画中没有滚动容器或旧胶囊形变', async () => { const bounds = new Set(); const timer = setInterval(() => bounds.add(JSON.stringify(win.getBounds())), 20); + const recorded: Array<{ at: number; image: Electron.NativeImage }> = []; + const capture = process.env.ISLAND_MOTION_CAPTURE === '1' && process.env.ELECTRON_SMOKE_MODE === 'visual'; + const started = Date.now(); + if (capture) + win.webContents.beginFrameSubscription(true, (image) => { + if (Date.now() - (recorded.at(-1)?.at ?? 0) >= 40) recorded.push({ at: Date.now(), image }); + }); let result; try { result = await win.webContents.executeJavaScript(`new Promise(resolve => { - const frames=[], gaps=[]; let last=performance.now(), count=0; + const frames=[], gaps=[], motion=[]; let last=performance.now(), count=0; const sample=()=>{ const card=document.querySelector('.resident-card'), r=card?.getBoundingClientRect(); if(!r){frames.push(false);return;} const nodes=[card,...card.querySelectorAll('*')]; frames.push(r.left>=0&&r.top>=0&&r.right<=innerWidth+1&&r.bottom<=innerHeight+1&&nodes.every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)&&!['auto','scroll'].includes(getComputedStyle(n).overflowX))); + motion.push({at:performance.now()-start,height:r.height,compact:Number(getComputedStyle(card.querySelector('.resident-compact-page')).opacity),details:Number(getComputedStyle(card.querySelector('.resident-detail-page')).opacity)}); }; const animate=time=>{gaps.push(time-last);last=time;count++;sample();if(time-start<1800)requestAnimationFrame(animate);}; const start=performance.now();requestAnimationFrame(animate); window.island.sendAction('expand');setTimeout(()=>window.island.sendAction('collapse'),700); - setTimeout(()=>{sample(); const card=document.querySelector('.resident-card');resolve({valid:frames.every(Boolean),count,gaps:gaps.filter(g=>g>0).sort((a,b)=>a-b),height:card.getBoundingClientRect().height,legacy:document.querySelector('.island-root')?.getBoundingClientRect().height===0});},1900); + setTimeout(()=>{sample(); const card=document.querySelector('.resident-card');resolve({valid:frames.every(Boolean),count,motion,gaps:gaps.filter(g=>g>0).sort((a,b)=>a-b),height:card.getBoundingClientRect().height,legacy:document.querySelector('.island-root')?.getBoundingClientRect().height===0});},1900); })`); } finally { clearInterval(timer); + if (capture) win.webContents.endFrameSubscription(); + } + if (capture && process.env.ISLAND_SHOTS_DIR) { + const dir = path.join(process.env.ISLAND_SHOTS_DIR, 'motion-frames'); + fs.mkdirSync(dir, { recursive: true }); + recorded.forEach((frame, i) => fs.writeFileSync(path.join(dir, `${i}.png`), frame.image.toPNG())); + fs.writeFileSync(path.join(dir, 'times.json'), JSON.stringify(recorded.map((f) => f.at - started))); } if (process.env.ISLAND_SHOTS_DIR) fs.writeFileSync( @@ -344,9 +430,41 @@ export async function verifyResidentSmoke( result.legacy && Math.abs(result.height - 40) <= 1 && bounds.size === 1 && - (!visual || (result.count >= 20 && result.gaps[Math.floor(result.gaps.length * 0.95)] < 100)), + (!visual || + (result.count >= 20 && + result.gaps[Math.floor(result.gaps.length * 0.95)] < 100 && + [ + result.motion.filter((f: { at: number }) => f.at < 700), + result.motion.filter((f: { at: number }) => f.at >= 700), + ].every( + (phase: Array<{ height: number; compact: number; details: number }>) => + new Set(phase.map((f) => Math.round(f.height))).size >= 3 && + phase.filter((f) => f.details > 0.01 && f.details < 0.95 && f.compact < 0.95).length >= 3, + ))), ); }); + await check('关闭动画后外壳和内容同时落位,恢复动画无需重启', async () => { + island.setAppearance({ animations: false }); + try { + island.handleAction({ action: 'expand' }); + await pause(100); + const opened = await win.webContents.executeJavaScript(`(() => { + const card=document.querySelector('.resident-card'); + return card.getBoundingClientRect().height>40&&getComputedStyle(card.querySelector('.resident-detail-page')).opacity==='1'&&getComputedStyle(card.querySelector('.resident-compact-page')).opacity==='0'; + })()`); + island.handleAction({ action: 'collapse' }); + await pause(100); + return ( + opened && + (await win.webContents.executeJavaScript(`(() => { + const card=document.querySelector('.resident-card'); + return Math.abs(card.getBoundingClientRect().height-40)<=1&&getComputedStyle(card.querySelector('.resident-compact-page')).opacity==='1'&&getComputedStyle(card.querySelector('.resident-detail-page')).opacity==='0'; + })()`)) + ); + } finally { + island.setAppearance({ animations: true }); + } + }); await check('常驻岛连续快速开合后恢复40px且没有遗留过渡', async () => { const box = win.getBounds(); const stable = await win.webContents.executeJavaScript(`new Promise(resolve=>{ @@ -375,6 +493,106 @@ export async function verifyResidentSmoke( await pause(700); return Math.abs(immediate - opacity) < 0.01 && Math.abs(after - opacity) < 0.01; }); + await check('两种布局均弹出ClassIsland课前/上下课提醒,倒计时与右侧时间正确且不写入通知队列', async () => { + const capture = process.env.ISLAND_MOTION_CAPTURE === '1' && process.env.ELECTRON_SMOKE_MODE === 'visual'; + const recorded: Array<{ at: number; image: Electron.NativeImage }> = []; + const start = Date.now(); + let recording = false; + const cueAppearance = island.getAppearance(); + const setTime = async (h: number, m: number, s = 0) => { + const at = dateAt(h, m, s).getTime(); + await win.webContents.executeJavaScript(`window.__lessonSmokeTime=${at}`); + island.setLessonReminderTestTime(at); + }; + try { + await win.webContents.executeJavaScript( + `window.__lessonRealNow=Date.now;Date.now=()=>window.__lessonSmokeTime??window.__lessonRealNow();true`, + ); + for (const layout of ['custom', 'fixed'] as const) { + saveConfig({ islandLayout: { ...fixture, layout } }); + island.syncCustomLayout(); + await setTime(8, 58, 59); + island.setClassState({ inClass: false, schedules: lessonFixtures }); + await pause(1100); + if (layout === 'fixed') { + island.setAppearance({ idleSliver: true }); + island.setTouchMode(true); + island.pushNotification(message('lesson-touch-canvas', '触摸画布验证', 'NORMAL')); + await pause(600); + island.handleAction({ action: 'dismiss-all' }); + await pause(600); + } + if (capture && layout === 'custom') { + recording = true; + win.webContents.beginFrameSubscription(true, (image) => { + if (Date.now() - (recorded.at(-1)?.at ?? 0) >= 40) recorded.push({ at: Date.now(), image }); + }); + } + await setTime(8, 59); + await pause(800); + if (island.getState().lessonReminder?.kind !== 'prepare') return false; + const mask = await win.webContents.executeJavaScript( + `(() => {const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'} .ci-mask')||document.querySelector('.ci-fixed-lesson .ci-mask');return Boolean(cue&&cue.querySelectorAll('polygon').length===5);})()`, + ); + if (!mask) return false; + await shot('lesson-prepare-mask-' + layout); + await setTime(8, 59, 5); + await pause(1100); + const info = await win.webContents.executeJavaScript(`(() => { + const cue=document.querySelector('${layout === 'custom' ? '.resident-lesson-page .ci-lesson' : '.ci-fixed-lesson'}'); + const name=cue?.querySelector('.ci-lesson-course strong')?.getBoundingClientRect(),time=cue?.querySelector('.ci-lesson-course small')?.getBoundingClientRect(); + const r=cue?.getBoundingClientRect(); + return Boolean(cue&&!cue.classList.contains('ci-mask-active')&&cue.textContent.includes('55')&&cue.textContent.includes('数学')&&cue.textContent.includes('09:00–09:45')&&name&&time&&time.left>=name.right&&r&&Math.abs(r.height-40)<=1&&r.left>=0&&r.right<=innerWidth&&r.bottom<=innerHeight); + })()`); + if (!info || island.getState().queued.length || island.getState().active) return false; + await shot('lesson-prepare-info-' + layout); + await setTime(9, 0); + island.setClassState({ inClass: true }); + await pause(700); + if (island.getState().lessonReminder?.kind !== 'on-class') return false; + await shot('lesson-start-' + layout); + if (recording) { + win.webContents.endFrameSubscription(); + recording = false; + } + await setTime(9, 0, 6); + await pause(700); + if (island.getState().lessonReminder) return false; + await setTime(9, 45); + island.setClassState({ inClass: false }); + await pause(700); + if (island.getState().lessonReminder?.kind !== 'break') return false; + await setTime(9, 45, 3); + await pause(1100); + const next = await win.webContents.executeJavaScript( + `document.querySelector('${layout === 'custom' ? '.resident-lesson-page' : '.ci-fixed-lesson'}')?.textContent.includes('语文')`, + ); + if (!next) return false; + await shot('lesson-break-' + layout); + await setTime(9, 45, 13); + if (island.getState().lessonReminder) return false; + } + return true; + } finally { + if (recording) win.webContents.endFrameSubscription(); + if (capture && process.env.ISLAND_SHOTS_DIR) { + const dir = path.join(process.env.ISLAND_SHOTS_DIR, 'lesson-motion-frames'); + fs.mkdirSync(dir, { recursive: true }); + recorded.forEach((f, i) => fs.writeFileSync(path.join(dir, `${i}.png`), f.image.toPNG())); + fs.writeFileSync(path.join(dir, 'times.json'), JSON.stringify(recorded.map((f) => f.at - start))); + } + await win.webContents.executeJavaScript( + `Date.now=window.__lessonRealNow;delete window.__lessonRealNow;delete window.__lessonSmokeTime`, + ); + island.setLessonReminderTestTime(null); + island.setClassState({ inClass: false, schedules: [] }); + island.setTouchMode(originalTouchMode); + island.setAppearance(cueAppearance); + saveConfig({ islandLayout: fixture }); + island.syncCustomLayout(); + await pause(800); + } + }); await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => { const appearance = island.getAppearance(); const dense = JSON.parse(JSON.stringify(fixture)); diff --git a/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue index 75ece7e..cd2b321 100644 --- a/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue +++ b/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue @@ -147,7 +147,7 @@ onMounted(async () => { 灵动岛 - 经典岛与自定义岛共用外观、停靠位置、动画与提醒规则 + 自定义组件与 ClassIsland 提醒,共用停靠位置、动画开关与通知规则 @@ -170,13 +170,13 @@ onMounted(async () => { @click="chooseLayout('custom')" > 自定义灵动岛 - 常驻显示时钟、课程等组件,通知沿用经典岛 + ClassIsland 风格,常驻显示时钟、课程等组件 按你的习惯布置岛内内容 - 编辑常态内容与无通知展开内容;通知样式、弹出规则和外观设置与经典岛共用。 + 编辑常态内容与无通知展开内容;课前自动提示,通知操作与经典岛共用。 编辑岛内组件 @@ -207,6 +207,9 @@ onMounted(async () => { 外观、位置与弹出 紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。 + + 上课前 60 秒显示准备提醒和倒计时,上下课时提示课程状态;课程时间显示在名称右侧。 + { 用于高亮、按钮与进度条 + ClassIsland · 半透明横条 applyIslandAppearance({ style: value })" @@ -282,7 +287,9 @@ onMounted(async () => { :value="item.value" /> - 纯黑 / 毛玻璃(半透明)/ 主题色渐变 + + 纯黑 / 毛玻璃(半透明)/ 主题色渐变 +
经典岛与自定义岛共用外观、停靠位置、动画与提醒规则
自定义组件与 ClassIsland 提醒,共用停靠位置、动画开关与通知规则
编辑常态内容与无通知展开内容;通知样式、弹出规则和外观设置与经典岛共用。
编辑常态内容与无通知展开内容;课前自动提示,通知操作与经典岛共用。
紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。
+ 上课前 60 秒显示准备提醒和倒计时,上下课时提示课程状态;课程时间显示在名称右侧。 +