From 3379eb975cc3649c58c68f3f768f4b145b95f121 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 17 Oct 2026 16:43:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(island):=20=E5=AF=B9=E9=BD=90=20ClassIslan?= =?UTF-8?q?d=20=E8=AF=BE=E8=A1=A8=E6=8F=90=E9=86=92=E5=B9=B6=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=BC=80=E5=90=88=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/20261017-implementation.md | 27 ++ docs/reference.md | 14 +- .../src/island/ComponentInspector.vue | 2 +- .../src/island/ComponentNode.vue | 21 +- .../src/island/CustomIsland.vue | 11 +- .../src/island/CustomIslandRuntime.vue | 106 ++++++- .../desktop-client/src/island/IslandApp.vue | 33 ++- .../src/island/LessonReminderContent.vue | 260 ++++++++++++++++++ .../src/island/classic-island.css | 10 + .../src/island/custom-runtime.css | 33 +-- packages/desktop-client/src/main/island.ts | 68 ++++- .../src/main/lesson-reminder.ts | 75 +++++ .../desktop-client/src/main/smoke-resident.ts | 226 ++++++++++++++- .../views/settings/IslandSettingsView.vue | 15 +- packages/shared/src/types.ts | 17 ++ 15 files changed, 853 insertions(+), 65 deletions(-) create mode 100644 packages/desktop-client/src/island/LessonReminderContent.vue create mode 100644 packages/desktop-client/src/main/lesson-reminder.ts 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(() => { @@ -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(() => {