diff --git a/docs/dev/20261017-implementation.md b/docs/dev/20261017-implementation.md index c9f1102..5f33517 100644 --- a/docs/dev/20261017-implementation.md +++ b/docs/dev/20261017-implementation.md @@ -130,3 +130,31 @@ Windows 触摸模式使用 Electron setShape:动画开始放开绘制区域, - 新旧课表界面与岛桥接共用的缓存仍保存整周数据,岛组件只接收当天投影,避免当天响应覆盖整周离线缓存。 - 最终复跑曾遇到本机冷启动超时:桌面第一次界面无响应,原样重跑在已通过的检查之后被旧 45 秒总限时中止;临时后端也超过了原 10 秒就绪等待。外层预算分别调整为 90 秒与 60 秒,心跳 30 秒等单项业务断言不变。 调整后最终复跑通过后端 243/243 与桌面 24/24(含二次启动),桌面整套实际耗时 20.6 秒。 + +## 常驻岛并入自定义灵动岛(最新行为) + +后续产品要求统一两种自定义显示方式,本节取代上文关于可选常驻、铃铛优先与独立通知卡的历史行为。 +`layout` 仍为 fixed/custom;custom 直接显示常态组件,`residentEnabled` 只保留为旧配置兼容字段,归一化为 custom 时开启。 +设置页不再提供独立的常驻开关。编辑器保留常态 rows 与无通知展开 expandedRows,两套内容随本机方案保存。 + +自定义运行时统一为 `CustomIslandRuntime.vue`,不再将旧卡片嵌入组件行。 +经典岛与自定义岛共同使用 `ClassicNotifications.vue` 和 `classic-island.css`,通知类型、排序、徽标、正文、按钮与已读动作只有一份实现。 +纯黑、玻璃、主题色、透明度、字号、圆角、动画开关与速度均来自同一份 island 外观设置;方案保留组件缩放、行距和布局。 +自定义组件行、消息正文与通知操作区居中对齐。无通知展开时显示 expandedRows,有通知时显示共用经典通知内容。 + +自定义模式不再提前截获所有消息并只亮铃铛:与经典岛走相同的 pushNotification/setClassState 状态机。 +紧急通知和紧急叫人立即展开,课中普通通知排队、下课后展开;主动点击铃铛仍可查看待处理消息。 +收起和处理完通知后恢复自定义常态内容。铃铛固定占位,收到消息不横移。空白桌面输入穿透与触摸区域裁剪继续保留。 +外壳使用经典岛同源的 SpringValue 与 squirclePath;宽高和圆角每帧同时推进,保留零过冲钳制、动画速度与后台吸附兜底。 +原生画布固定,隐藏渲染器不运行旧形变;统一心跳不随布局切换重复注册。 + +课表更新链路做了以下修正: + +- 课表变更事件立即取 active-week,学期接口在旁路校正,避免慢学期请求串行挡住新课表。 +- 网络结果先同步到岛,再异步完成缓存写入;请求序号在请求开始前分配,迟到响应和已切换账号的结果不会覆盖当前内容。 +- 实时连接建立及重连时立即刷新;周期补偿从五分钟缩至三十秒,课程边界每秒复算。跨午夜即使上课状态未改变,也同步新一天的课程。 +- 主进程保留最新日课表快照;窗口启动、重建时补发,渲染器通过受限 getSchedules IPC 读回,登出时清空。 +- 自适应同时接收原生窗口尺寸通知,防止隐藏窗口的浏览器 resize 延迟而沿用旧宽度;订阅在组件退出时释放。 + +验证使用隔离配置、本地消息和替换的本地 API,覆盖慢学期接口、变更事件、重连刷新、迟到请求隔离、课表 IPC 即时更新及窗口重新加载恢复。 +桌面视觉回归覆盖经典岛原有状态,以及自定义岛单通知、多通知、常态、空闲展开、窄屏、长文本、触摸和固定原生窗口动画。 diff --git a/packages/desktop-client/src/island/ClassicNotifications.vue b/packages/desktop-client/src/island/ClassicNotifications.vue new file mode 100644 index 0000000..8c7ef62 --- /dev/null +++ b/packages/desktop-client/src/island/ClassicNotifications.vue @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + {{ isListMode ? `共 ${pendingCount} 条` : badgeText }} + + + {{ pendingTypes.join(' / ') }} + 按重要程度排列 + + + 老师正在等你 + 需立即查看 + 下课后补发 + {{ subtitle }} + + + + + + + + + + + + {{ pendingCount }} 条待处理通知 + + + + + + + + + + + + + + + {{ item.title }} + {{ rowSubText(item) }} + + {{ rowBadgeText(item) }} + + + + + 展开更多(还有 {{ hiddenCount }} 条) + + 更多请前往应用内操作 + + + + + + {{ notification?.title ?? '' }} + + {{ notification?.content ?? '' }} + {{ callHint }} + {{ notification?.subtitle }} + + + + + + + + diff --git a/packages/desktop-client/src/island/ComponentNode.vue b/packages/desktop-client/src/island/ComponentNode.vue index a76652b..5ab4305 100644 --- a/packages/desktop-client/src/island/ComponentNode.vue +++ b/packages/desktop-client/src/island/ComponentNode.vue @@ -110,7 +110,7 @@ const style = computed(() => { : ({ stretch: 'stretch', start: 'flex-start', center: 'center', end: 'flex-end' } as const)[ n.advanced.align ], - textAlign: n.advanced.align === 'center' ? 'center' : 'start', + textAlign: n.advanced.align === 'start' ? 'start' : n.advanced.align === 'end' ? 'end' : 'center', color: override && a.customForegroundEnabled ? a.foreground : undefined, background: override && a.customBackgroundEnabled ? color : undefined, borderRadius: override && a.customCornerRadiusEnabled ? `${a.cornerRadius}px` : undefined, diff --git a/packages/desktop-client/src/island/CustomIsland.vue b/packages/desktop-client/src/island/CustomIsland.vue index e9500b8..d965f95 100644 --- a/packages/desktop-client/src/island/CustomIsland.vue +++ b/packages/desktop-client/src/island/CustomIsland.vue @@ -7,6 +7,7 @@ import { islandNode, normalizeIslandLayouts, type IslandLayoutSettings, + type IslandAppearance, type IslandComponentKind, type IslandComponentNode, type IslandState, @@ -19,6 +20,7 @@ const props = defineProps<{ settings: IslandLayoutSettings; state: IslandState; editing: boolean; + appearance: IslandAppearance; schedules: ScheduleDto[]; }>(); const emit = defineEmits<{ @@ -84,17 +86,6 @@ function collapseResident() { function onPointerDown(event: PointerEvent) { if (event.pointerType === 'touch' || event.pointerType === 'pen') window.island?.noteResidentTouch(); } -function restoreClassIslandAppearance() { - Object.assign(scheme.value, { - scale: CI.scale, - color: '#000000', - opacity: CI.opacity, - radius: CI.radius, - spacing: CI.rowSpacing, - }); - for (const row of [...scheme.value.rows, ...scheme.value.expandedRows]) row.gap = 12; - save(); -} const selectedId = ref(''), selectedKind = ref('clock'); const panel = ref<'component' | 'row' | 'appearance'>('appearance'); @@ -158,14 +149,15 @@ const shellStyle = computed(() => ({ minHeight: !props.editing && viewport.value.height ? `${viewport.value.height}px` : undefined, maxWidth: `calc(100vw - ${2 * ISLAND_SHADOW_PAD}px)`, maxHeight: `calc(100vh - ${2 * ISLAND_SHADOW_PAD}px)`, - color: scheme.value.foreground, - fontSize: '16px', + color: '#ffffffeb', + fontSize: `${props.appearance.fontSize}px`, '--custom-color': - scheme.value.color + - Math.round(scheme.value.opacity * 255) - .toString(16) - .padStart(2, '0'), - '--custom-radius': `${scheme.value.radius * (draft.value.residentEnabled ? scheme.value.scale : 1)}px`, + props.appearance.style === 'glass' + ? '#202024cd' + : props.appearance.style === 'tinted' + ? '#0b0b0f' + : '#000000', + '--custom-radius': `${props.appearance.radius}px`, '--custom-row-gap': `${scheme.value.spacing}px`, ...(draft.value.residentEnabled ? { @@ -364,6 +356,7 @@ function panMove(e: PointerEvent) { pan.value = { x: panning.originX + e.clientX - panning.x, y: panning.originY + e.clientY - panning.y }; move(e); } +let stopViewport: (() => void) | undefined; let observer: ResizeObserver | undefined, measureTimer: ReturnType | undefined, clock: ReturnType; @@ -399,12 +392,13 @@ onMounted(() => { clock = setInterval(() => (now.value = Date.now()), 1000); observer = new ResizeObserver(measure); if (inner.value) observer.observe(inner.value); - window.island?.onViewport((v) => (viewport.value = v)); + stopViewport = window.island?.onViewport((v) => (viewport.value = v)); measure(); }); onUnmounted(() => { clearInterval(clock); clearTimeout(measureTimer); + stopViewport?.(); observer?.disconnect(); }); @@ -495,15 +489,14 @@ onUnmounted(() => { - + - 收起时 + 常态内容 无通知展开时 - 岛内预览 { - - 常驻岛 - - - 开启后始终显示收起内容,收到消息时显示铃铛,点击查看通知;没有通知时展开另一套自定义内容。 + 通知、外观与弹出动画沿用经典灵动岛。在应用的灵动岛设置中调整颜色、字号、圆角和动画速度。 - - ClassIsland 窗口设置 - - 基准岛高 40 px,默认缩放 1×;正文 16、时钟 18 px。背景移入淡化并穿透,右侧展开按钮保持可用。 - - - 指针移入淡化 - - - - 反转指针移入淡化 - - - - 恢复 ClassIsland 默认外观 - - 当前方案 @@ -797,40 +769,6 @@ onUnmounted(() => { /> - - 岛体 - - 背景颜色 - - - - 文字颜色 - - - - 背景透明度 - - - {{ Math.round(scheme.opacity * 100) }}% - - - - 圆角 - - - 多行布局 diff --git a/packages/desktop-client/src/island/ResidentIsland.vue b/packages/desktop-client/src/island/CustomIslandRuntime.vue similarity index 67% rename from packages/desktop-client/src/island/ResidentIsland.vue rename to packages/desktop-client/src/island/CustomIslandRuntime.vue index 2bfce87..4f8ce90 100644 --- a/packages/desktop-client/src/island/ResidentIsland.vue +++ b/packages/desktop-client/src/island/CustomIslandRuntime.vue @@ -1,9 +1,7 @@ - { :settings="settings" :state="state" :editing="editing" + :appearance="appearance" :schedules="schedules" @save="save" @done="done" diff --git a/packages/desktop-client/src/island/classic-island.css b/packages/desktop-client/src/island/classic-island.css new file mode 100644 index 0000000..7b8c52e --- /dev/null +++ b/packages/desktop-client/src/island/classic-island.css @@ -0,0 +1,743 @@ +/* + * 排版系数(必须与 @classhelper/shared 的 ISLAND_TYPE_SCALE 保持一致): + * title 1.08 / body 0.95 / meta 0.78 / badge 0.76 / pillTitle 0.92 / pillSub 0.74 + * + * 关键:所有文本尺寸都由 --island-font 通过 calc() 推导,所以「字号」设置一改, + * 岛内文字立即整体等比缩放(WinIsland 的 font_size 模型),不存在写死的字号。 + */ +html, +body, +#app { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: transparent; + overflow: hidden; + -webkit-user-select: none; + user-select: none; +} + +body { + font-family: + -apple-system, 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', + 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +.island-root { + width: 100%; + height: 100%; + position: relative; + background: transparent; +} + +/* ---------------------------------------------------------------- 卡片与形状 */ + +.island-root.embedded .island-card { + left: 0 !important; + top: 0 !important; + right: auto !important; + bottom: auto !important; + transform: none !important; +} + +.island-card { + position: absolute; + display: block; + padding: 0; + margin: 0; + border: none; + background: none; + color: rgba(255, 255, 255, 0.92); + font-size: var(--island-font, 13px); + text-align: left; + cursor: pointer; + /* 配色 token(默认:WinIsland 纯黑风格) */ + --wn-bg: #000000; + --wn-border: rgba(255, 255, 255, 0.118); + --wn-text-1: rgba(255, 255, 255, 0.92); + --wn-text-2: rgba(255, 255, 255, 0.72); + --wn-text-3: rgba(255, 255, 255, 0.58); + --wn-surface: rgba(255, 255, 255, 0.1); + --wn-surface-hover: rgba(255, 255, 255, 0.18); + --wn-divider: rgba(255, 255, 255, 0.09); +} + +.island-card[data-style='glass'] { + /* WinIsland「glass 无 host backdrop」降级色:比纯黑透一点,但保证任何桌面下都不是白底 */ + --wn-bg: rgba(32, 32, 36, 0.804); + --wn-border: rgba(255, 255, 255, 0.157); + --wn-surface: rgba(255, 255, 255, 0.14); + --wn-surface-hover: rgba(255, 255, 255, 0.22); +} + +.island-card[data-style='tinted'] { + --wn-bg: #0b0b0f; + --wn-border: rgba(255, 255, 255, 0.14); +} + +.island-card.hidden-placeholder { + visibility: hidden; +} + +/* 连续圆角形状:填充 + 1px 描边(描边画在裁切之外,与 WinIsland 一致) */ +.island-card .shape { + position: absolute; + left: 0; + top: 0; + display: block; +} + +.island-card .shape path { + fill: var(--wn-bg); + stroke: var(--wn-border); + stroke-width: 1; + /* + 投影**始终声明**,形态切换只改参数 —— 不要写成"只在 .expanded 上挂 filter"。 + 给元素加 / 去 CSS filter 会让 Chromium 新建或销毁它的渲染表面(effect node), + 而首帧的合成可能发生在新表面栅格化完成之前,那一帧会被当作空内容画出去; + 卡片的底色恰恰就画在这个被过滤的元素上(fill 在本元素),于是**开/合的那一瞬间 + 底座会闪掉一帧、只剩未过滤的文字层**(用户反馈的"开合时一瞬间的闪动")。 + 胶囊态用 0 0 0:硬轮廓投影正好压在路径底下,肉眼等同无投影,与 WinIsland + "只有展开态有投影"的观感一致。 + */ + filter: drop-shadow(0 var(--card-shadow-y, 0px) var(--card-shadow-blur, 0px) rgba(0, 0, 0, 0.11)); +} + +/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */ +.island-card .content { + position: absolute; + inset: 0; + display: block; + clip-path: var(--card-path); + overflow: hidden; +} + +.island-card .layer { + position: absolute; + inset: 0; + display: flex; + pointer-events: none; +} + +/* 只有"当前形态"的那一层响应鼠标,避免透明层的按钮被误点 */ +.island-card.pill .pill-layer, +.island-card.expanded .expanded-layer { + pointer-events: auto; +} + +/* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px)。 + 只改参数、不动 filter 声明本身,理由见上面的 .shape path。 */ +.island-card.expanded .shape path { + --card-shadow-y: 2px; + --card-shadow-blur: 3px; +} + +/* 状态色:只做"内层微染 + 描边",不外溢(保持桌面干净,也不会有光晕硬边) */ +.island-card.urgent .shape path, +.island-card.call .shape path { + stroke: color-mix(in srgb, var(--state-color) 62%, rgba(255, 255, 255, 0.2)); +} + +.island-card .tint { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; +} + +.island-card.urgent .tint { + opacity: 1; + background: radial-gradient(120% 90% at 50% 0%, rgba(255, 69, 58, 0.22), transparent 68%); +} + +.island-card.call .tint { + opacity: 1; + background: radial-gradient(120% 90% at 50% 0%, rgba(255, 159, 10, 0.2), transparent 68%); +} + +.island-card[data-style='tinted'] .tint { + opacity: 1; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--island-accent) 30%, transparent), + transparent 62% + ); +} + +/* ---------------------------------------------------------------- 胶囊(compact) */ + +.pill-layer { + flex-direction: row; + align-items: center; + /* 水平对齐交给 .pill-inner(它固定为胶囊几何,且按停靠方式贴边/居中) */ + justify-content: flex-start; +} + +/** + * 胶囊内容盒:**固定为胶囊自己的宽高与内边距**,不随卡片形变重排。 + * + * 为什么必须固定:卡片在开合时 268 ⇄ 424 变宽变窄,而中心停靠下卡片的左右边都会动。 + * 内容若跟着卡片宽度走,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回" + * (用户反馈的"新消息那行字往右跳一下再缩回"),底部也会被收缩的形状裁掉一块。 + * 固定成胶囊几何后:文字在整段动画里位置与省略号都不变,只是随卡片一起淡出/淡入。 + * + * 贴哪条边与卡片停靠方式一致(左停靠贴左、右停靠贴右、居中则居中对齐)—— + * 这样卡片变宽时内容也不会横移,静止态外观与之前完全一致。 + */ +.pill-inner { + display: inline-flex; + flex-direction: row; + align-items: center; + flex: 0 0 auto; + gap: calc(var(--island-font, 13px) * 0.62); + width: var(--island-w, 268px); + height: 100%; + padding: 0 calc(var(--island-font, 13px) * 0.78); +} + +.island-card[data-anchor='center'] .pill-layer { + justify-content: center; +} + +.island-card[data-anchor='right'] .pill-layer { + justify-content: flex-end; +} + +/** + * 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。 + * + * 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出 + * 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`, + * 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏, + * 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。 + */ +.island-card.closing .pill-inner { + opacity: 0; +} + +.pill-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: calc(var(--island-font, 13px) * 1.24); + height: calc(var(--island-font, 13px) * 1.24); + border-radius: 50%; + background: var(--wn-surface); + color: var(--wn-text-1); +} + +.pill-icon svg { + width: calc(var(--island-font, 13px) * 0.86); + height: calc(var(--island-font, 13px) * 0.86); +} + +.pill-icon.call { + color: #ff9f0a; +} + +.pill-icon.urgent { + color: #ff453a; +} + +.pill-icon.homework { + color: var(--island-accent); +} + +.pill-text { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + flex: 1 1 auto; + gap: 1px; +} + +.pill-title { + font-size: calc(var(--island-font, 13px) * 0.92); + font-weight: 600; + line-height: 1.15; + color: var(--wn-text-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pill-sub { + font-size: calc(var(--island-font, 13px) * 0.74); + line-height: 1.15; + color: var(--wn-text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 多条消息时把类型做成胶囊小标签(叫人/作业/通知),收起状态也能一眼看出内容构成 */ +.pill-types { + display: inline-flex; + align-items: center; + gap: calc(var(--island-font, 13px) * 0.2); + flex: 0 0 auto; + padding-right: calc(var(--island-font, 13px) * 0.2); +} + +.pill-type { + font-size: calc(var(--island-font, 13px) * 0.6); + line-height: 1.4; + padding: 0 calc(var(--island-font, 13px) * 0.36); + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + color: var(--wn-text-2); + white-space: nowrap; +} + +.pill-chevron { + display: inline-flex; + align-items: center; + color: rgba(255, 255, 255, 0.34); + flex: 0 0 auto; +} + +.pill-chevron svg { + width: calc(var(--island-font, 13px) * 0.86); + height: calc(var(--island-font, 13px) * 0.86); +} + +/* ---------------------------------------------------------------- 展开卡(expanded) */ + +.expanded-layer { + /* 关键:展开层必须纵向排列。此前把 flex-direction 从基类挪到分层时漏了这一条, + 导致展开卡内容横排(标题/元信息/按钮散落,实机可见) */ + flex-direction: column; + padding: calc(var(--island-font, 13px) * 1.4) calc(var(--island-font, 13px) * 1.6); + gap: calc(var(--island-font, 13px) * 0.7); + justify-content: center; +} + +/* + * 多条通知的列表形态。 + * + * 这里的每个尺寸都必须与 @classhelper/shared 的 `ISLAND_LIST_METRICS` 一一对应: + * 主进程按那份度量算出**窗口包围盒**(列表比普通展开卡高,窗口不够高会把底部按钮裁掉), + * 渲染进程按同一份度量排布。改这里就必须改那里,否则会出现"最后一行被切掉"。 + * 选择器一律写成 `.expanded-layer.list-mode ...`(比基类的单类选择器更具体), + * 免得被后面定义的 `.title` / `.body` 覆盖掉尺寸——那样高度就对不上算出来的值了。 + */ +.expanded-layer.list-mode { + /* 高度已经算准,改 flex-start:居中排版一旦有像素级误差会把上下两端同时切掉 */ + justify-content: flex-start; +} + +.expanded-layer.list-mode .head { + height: calc(var(--island-font, 13px) * 1.7); +} + +.expanded-layer.list-mode .title { + flex: 0 0 auto; + display: block; + height: calc(var(--island-font, 13px) * 1.4); + line-height: 1.4; + -webkit-line-clamp: unset; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.expanded-layer.list-mode .body { + flex: 1 1 auto; + min-height: 0; + gap: calc(var(--island-font, 13px) * 0.4); + justify-content: flex-start; + overflow: hidden; +} + +.head-icon.list { + background: color-mix(in srgb, var(--island-accent) 20%, transparent); + color: var(--island-accent); +} + +.list-row { + display: flex; + align-items: center; + gap: calc(var(--island-font, 13px) * 0.55); + flex: 0 0 auto; + width: 100%; + height: calc(var(--island-font, 13px) * 2.7); + padding: 0 calc(var(--island-font, 13px) * 0.6); + border: none; + border-radius: calc(var(--island-font, 13px) * 0.5); + background: rgba(255, 255, 255, 0.045); + color: var(--wn-text-1); + text-align: left; + cursor: pointer; + transition: background var(--ch-dur-fast) var(--ch-ease-out); +} + +.row-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: calc(var(--island-font, 13px) * 1.3); + height: calc(var(--island-font, 13px) * 1.3); + border-radius: calc(var(--island-font, 13px) * 0.36); + background: var(--wn-surface); + color: var(--wn-text-2); +} + +.row-icon svg { + width: calc(var(--island-font, 13px) * 0.86); + height: calc(var(--island-font, 13px) * 0.86); +} + +.row-icon.call { + color: #ff9f0a; +} + +.row-icon.homework { + color: var(--island-accent); +} + +.row-text { + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + min-width: 0; + flex: 1 1 auto; +} + +.row-title { + font-size: calc(var(--island-font, 13px) * 0.9); + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-sub { + font-size: calc(var(--island-font, 13px) * 0.68); + line-height: 1.1; + color: var(--wn-text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-badge { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + height: calc(var(--island-font, 13px) * 1.25); + padding: 0 calc(var(--island-font, 13px) * 0.5); + border-radius: 999px; + font-size: calc(var(--island-font, 13px) * 0.68); + font-weight: 600; + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.78); +} + +/* 列表底部的提示行:能展开时是按钮,屏幕到任务栏了就是一句"去应用里看" */ +.list-hint { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + height: calc(var(--island-font, 13px) * 2.1); +} + +.more-btn { + border: none; + background: var(--wn-surface); + color: var(--wn-text-1); + height: 100%; + width: 100%; + border-radius: calc(var(--island-font, 13px) * 0.5); + font-size: calc(var(--island-font, 13px) * 0.76); + font-weight: 600; + cursor: pointer; + transition: background var(--ch-dur-fast) var(--ch-ease-out); +} + +.more-app { + font-size: calc(var(--island-font, 13px) * 0.76); + color: var(--wn-text-3); + white-space: nowrap; +} + +.head { + display: flex; + align-items: center; + gap: calc(var(--island-font, 13px) * 0.7); + flex: 0 0 auto; +} + +.head-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(var(--island-font, 13px) * 1.62); + height: calc(var(--island-font, 13px) * 1.62); + border-radius: calc(var(--island-font, 13px) * 0.5); + background: var(--wn-surface); + color: var(--wn-text-1); + flex: 0 0 auto; +} + +.head-icon svg { + width: calc(var(--island-font, 13px) * 1.05); + height: calc(var(--island-font, 13px) * 1.05); +} + +.head-icon.urgent { + background: rgba(255, 69, 58, 0.2); + color: #ff453a; +} + +.head-icon.call { + background: rgba(255, 159, 10, 0.2); + color: #ff9f0a; +} + +.head-icon.homework { + background: color-mix(in srgb, var(--island-accent) 22%, transparent); + color: var(--island-accent); +} + +.head-meta { + display: flex; + align-items: center; + gap: calc(var(--island-font, 13px) * 0.55); + min-width: 0; + flex: 1 1 auto; +} + +.badge { + display: inline-flex; + align-items: center; + height: calc(var(--island-font, 13px) * 1.5); + padding: 0 calc(var(--island-font, 13px) * 0.62); + border-radius: 999px; + font-size: calc(var(--island-font, 13px) * 0.76); + font-weight: 600; + letter-spacing: 0.02em; + background: color-mix(in srgb, var(--island-accent) 26%, transparent); + color: color-mix(in srgb, var(--island-accent) 70%, #ffffff); + flex: 0 0 auto; +} + +.badge-urgent { + background: rgba(255, 69, 58, 0.24); + color: #ff6961; +} + +.badge-high { + background: rgba(255, 159, 10, 0.22); + color: #ffb340; +} + +.badge-low, +.badge-normal { + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.78); +} + +.badge-call { + background: rgba(255, 159, 10, 0.24); + color: #ffb340; +} + +.badge-homework { + background: color-mix(in srgb, var(--island-accent) 26%, transparent); + color: color-mix(in srgb, var(--island-accent) 72%, #ffffff); +} + +.live-dot { + display: inline-flex; + align-items: center; + gap: calc(var(--island-font, 13px) * 0.32); + font-size: calc(var(--island-font, 13px) * 0.76); + color: #ff6961; + white-space: nowrap; +} + +.live-dot.call { + color: #ffb340; +} + +.live-dot.muted { + color: var(--wn-text-3); +} + +.head-sub { + font-size: calc(var(--island-font, 13px) * 0.78); + color: var(--wn-text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: calc(var(--island-font, 13px) * 1.7); + height: calc(var(--island-font, 13px) * 1.7); + border: none; + border-radius: 50%; + background: var(--wn-surface); + color: rgba(255, 255, 255, 0.72); + cursor: pointer; + flex: 0 0 auto; + transition: + background var(--ch-dur-fast) var(--ch-ease-out), + color var(--ch-dur-fast) var(--ch-ease-out); +} + +.icon-btn svg { + width: calc(var(--island-font, 13px) * 0.8); + height: calc(var(--island-font, 13px) * 0.8); +} + +.title { + margin: 0; + font-size: calc(var(--island-font, 13px) * 1.08); + font-weight: 700; + line-height: 1.3; + color: #fff; + flex: 0 0 auto; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.body { + flex: 0 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: calc(var(--island-font, 13px) * 0.35); + overflow: hidden; +} + +.content-text { + margin: 0; + font-size: calc(var(--island-font, 13px) * 0.95); + line-height: 1.42; + color: var(--wn-text-2); + word-break: break-word; +} + +.call-hint { + margin: 0; + font-size: calc(var(--island-font, 13px) * 0.85); + line-height: 1.4; + color: color-mix(in srgb, var(--state-color) 66%, #ffffff); + word-break: break-word; +} + +.foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(var(--island-font, 13px) * 0.7); + flex: 0 0 auto; + margin-top: calc(var(--island-font, 13px) * 0.5); + padding-top: calc(var(--island-font, 13px) * 0.7); + border-top: 1px solid var(--wn-divider); +} + +.more { + font-size: calc(var(--island-font, 13px) * 0.78); + color: var(--wn-text-2); + white-space: nowrap; +} + +.more.muted { + color: var(--wn-text-3); +} + +.actions { + display: flex; + align-items: center; + gap: calc(var(--island-font, 13px) * 0.5); + flex: 0 0 auto; +} + +.ghost-btn, +.solid-btn { + height: calc(var(--island-font, 13px) * 2.05); + padding: 0 calc(var(--island-font, 13px) * 0.9); + border: none; + border-radius: 999px; + font-size: calc(var(--island-font, 13px) * 0.76); + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: + background var(--ch-dur-fast) var(--ch-ease-out), + transform var(--ch-dur-instant) var(--ch-ease-out); +} + +.ghost-btn { + background: var(--wn-surface); + color: var(--wn-text-1); +} + +.solid-btn { + background: #ffffff; + color: #0b0b0f; +} + +.island-card.call .solid-btn { + background: #ff9f0a; + color: #1a1205; +} + +.island-card.urgent .solid-btn { + background: #ff453a; + color: #ffffff; +} + +.ghost-btn:active, +.solid-btn:active { + transform: scale(0.97); +} + +/* --------------------------------------------------------------- 悬停微交互 + * + * **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏 + * 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」 + * 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。 + * 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。 + */ +@media (hover: hover) and (pointer: fine) { + .list-row:hover { + background: var(--wn-surface); + } + + .more-btn:hover, + .ghost-btn:hover { + background: var(--wn-surface-hover); + } + + .icon-btn:hover { + background: var(--wn-surface-hover); + color: #ffffff; + } +} + +/* 同一个通知组件用于经典岛绝对层与自定义岛自然测量层。 */ +.classic-notifications { + display: flex; + flex-direction: column; + flex-shrink: 0; +} diff --git a/packages/desktop-client/src/island/resident-island.css b/packages/desktop-client/src/island/custom-runtime.css similarity index 68% rename from packages/desktop-client/src/island/resident-island.css rename to packages/desktop-client/src/island/custom-runtime.css index 4b41b3c..9d8de65 100644 --- a/packages/desktop-client/src/island/resident-island.css +++ b/packages/desktop-client/src/island/custom-runtime.css @@ -1,4 +1,4 @@ -/* ClassIsland ClassicTheme: 40px 行高、12px 内边距、550ms 背景、200ms 内容淡入。 */ +/* 自定义岛:共用经典岛形状/动效与通知排版,组件行采用居中自然尺寸并自适应。 */ .resident-window { position: fixed; inset: 10px; @@ -16,18 +16,14 @@ max-width: none; max-height: none; overflow: hidden; - overflow: clip; + clip-path: var(--card-path); isolation: isolate; pointer-events: auto; - background: var(--resident-background); + background: none; border-radius: var(--resident-radius); - font-size: 16px; + font-size: var(--island-font); line-height: 1.4; - box-shadow: 0 2px 3px #0000001c; - transition: - width var(--resident-duration) cubic-bezier(0, 1, 0.5, 1.1), - height var(--resident-duration) cubic-bezier(0, 1, 0.5, 1.1), - border-radius var(--resident-fade-duration) ease; + box-shadow: none; } .resident-window[data-anchor='left'] .resident-card { left: 0; @@ -86,7 +82,7 @@ align-items: center; gap: 6px; padding: 0 12px; - min-height: 40px; + min-height: max(40px, calc(var(--island-font) * 2.5)); } .resident-window .resident-card.resident-separated { background: transparent; @@ -109,17 +105,20 @@ .resident-card .layout-rows { display: flex; flex-direction: column; + align-items: center; + justify-content: center; min-width: 0; } .resident-card .row-components { display: flex; align-items: center; + justify-content: center; gap: 0; - min-height: 40px; + min-height: max(40px, calc(var(--island-font) * 2.5)); } .resident-card .resident-compact .component-node:not(.container) { - min-height: 40px; - max-height: 40px; + min-height: max(40px, calc(var(--island-font) * 2.5)); + display: flex; align-items: center; padding: 0 calc(var(--row-gap, 12px) / 2); @@ -158,6 +157,7 @@ .resident-card .resident-details { min-width: 160px; padding: 0 12px 12px; + text-align: center; } .resident-card .resident-panel-heading { display: flex; @@ -193,89 +193,6 @@ width: 100%; max-width: 100%; } -.resident-card .resident-messages { - display: flex; - flex-direction: column; - gap: 8px; -} -.resident-card .list-row { - display: block; - height: auto; - min-height: 0; - padding: 12px; - border: 1px solid #ffffff18; - border-radius: 6px; - background: #ffffff08; -} -.resident-card .resident-message-title { - display: flex; - align-items: center; - gap: 12px; -} -.resident-card .resident-message-title strong { - flex: 1; - min-width: 0; - font-size: 16px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.resident-card .resident-message-title > span { - flex: none; - color: #ffffff80; - font-size: 11px; -} -.resident-card .list-row[data-priority='URGENT'] .resident-message-title > span { - color: #ff9c94; -} -.resident-card .list-row p { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - margin: 6px 0; - font-size: 14px; - line-height: 1.5; - overflow-wrap: anywhere; - white-space: pre-line; -} -.resident-card .list-row:only-of-type p { - -webkit-line-clamp: 5; -} -.resident-card .list-row small { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - color: #ffffff80; -} -.resident-card .resident-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - padding-top: 4px; -} -.resident-card .resident-actions button:first-child { - margin-right: auto; -} -.resident-card .resident-actions button, -.resident-card .resident-more { - border: 0; - border-radius: 6px; - padding: 8px 10px; - background: #ffffff0d; - color: inherit; - font: inherit; - font-size: 12px; - cursor: pointer; - white-space: nowrap; -} -.resident-card .resident-actions .solid-btn { - background: #ffffffdd; - color: #202124; -} .resident-card button:focus-visible { outline: 2px solid #adc4ff; outline-offset: -2px; @@ -302,3 +219,37 @@ opacity: 1; } } + +.resident-card .resident-details.with-messages { + padding: 0; + min-width: 0; +} +.resident-card .classic-notifications { + position: relative; + text-align: center; +} +.resident-card .classic-notifications .head-meta { + justify-content: center; +} +.resident-card .classic-notifications .foot { + justify-content: center; + flex-wrap: wrap; +} +.resident-card .classic-notifications .row-text { + align-items: center; +} +.resident-card .classic-notifications .row-title, +.resident-card .classic-notifications .row-sub { + max-width: 100%; +} +.resident-card .resident-source .component-node { + text-align: center; + justify-content: center; +} +.resident-card .resident-compact .schedule > div { + align-items: center; +} +.resident-card.resident-separated > .shape, +.resident-card.resident-separated > .tint { + visibility: hidden; +} diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index ccadaf1..5dc8476 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -203,7 +203,7 @@ class IslandController { return getConfig().islandLayout.layout === 'custom'; } private get residentEnabled(): boolean { - return this.customLayout && getConfig().islandLayout.residentEnabled; + return this.customLayout; } private residentWasEnabled = false; private residentFaded = false; @@ -514,11 +514,17 @@ class IslandController { * 用户看到的就是「点了没反应 / 点开又立刻缩回去」。 * 因此这里再用**指针位置**兜一层:指针还在岛上就绝不收起(详见 isCursorOnIsland)。 */ + win.on('resize', () => { + if (this.customLayout && !this.editingLayout && this.ready && !win.isDestroyed()) { + const { width, height } = win.getContentBounds(); + win.webContents.send('island:viewport', { width, height }); + } + }); win.on('blur', () => { if (this.editingLayout) return; // 冒烟里可以临时关掉"失焦收起",避免测试过程被无关的焦点变化打断(默认开启) if (!this.blurCollapseEnabled) return; - if (this.state.mode !== 'expanded' || (this.state.inClass && !this.residentEnabled)) return; + if (this.state.mode !== 'expanded') return; // 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点) if (this.previewId && this.state.active?.id === this.previewId) return; const sinceActivated = Date.now() - this.state.updatedAt; @@ -582,6 +588,7 @@ class IslandController { } // 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值 this.emitAppearance(); + this.win?.webContents.send('island:schedules', this.schedules); this.emit(); if (this.residentEnabled) { this.residentWasEnabled = true; @@ -594,6 +601,11 @@ class IslandController { return this.win; } + private schedules: ContractScheduleDto[] = []; + getSchedules(): ContractScheduleDto[] { + return [...this.schedules]; + } + getState(): IslandState { return { ...this.state, queued: [...this.state.queued] }; } @@ -740,22 +752,6 @@ class IslandController { } if (this.previewId && this.previewId !== notification.id) this.endPreview(this.previewId); - if (this.residentEnabled) { - // 常驻模式按用户选择先亮铃铛,普通/紧急/叫人均由点击打开;已经在看通知时继续显示列表。 - const showingNotifications = this.state.mode === 'expanded' && this.state.active !== null; - if (this.state.active && this.state.active.id !== notification.id) - this.state.queued = [ - ...this.state.queued.filter((item) => item.id !== this.state.active!.id), - this.state.active, - ]; - this.activate( - notification, - notification.priority === 'URGENT' ? 'urgent' : 'new', - showingNotifications ? 'expanded' : 'pill', - ); - return; - } - if (this.isImmediate(notification)) { // 插播:如果正在展示别的消息,把它放回队列 if (this.state.active && this.state.active.id !== notification.id) { @@ -807,18 +803,15 @@ class IslandController { /** 上课状态变化:进入上课隐藏;下课则自动弹出队列 */ setClassState(payload: IslandClassStatePayload): void { - if (Array.isArray(payload.schedules)) - this.win?.webContents.send('island:schedules', payload.schedules.slice(0, 100)); + if (Array.isArray(payload.schedules)) { + this.schedules = payload.schedules.slice(0, 100); + if (this.ready && this.win && !this.win.isDestroyed()) + this.win.webContents.send('island:schedules', this.schedules); + } const wasInClass = this.state.inClass; this.state.inClass = payload.inClass; this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null; - if (this.residentEnabled) { - this.state.updatedAt = Date.now(); - this.emit(); - return; - } - if (payload.inClass) { if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏'); this.explicitShowId = null; @@ -1114,6 +1107,10 @@ class IslandController { if (!this.win || this.win.isDestroyed()) return; // 焦点/可激活性统一由 applyFocusable 维护(窗口必须始终可激活,否则丢 mousedown) if (this.win.isFocused()) this.win.blur(); + if (this.customLayout) { + this.syncWindow(); + return; + } if (this.win.isVisible()) this.win.hide(); this.win.setOpacity(1); } @@ -2223,6 +2220,9 @@ export const island = new IslandController(); /** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */ export function registerIslandIpc(): void { + ipcMain.handle('island:get-schedules', (event) => + event.sender === island.getWindow()?.webContents ? island.getSchedules() : [], + ); ipcMain.handle('island:get-layout', () => getConfig().islandLayout); ipcMain.handle('island:save-layout', (_event, value: unknown) => { const result = saveConfig({ islandLayout: normalizeIslandLayouts(value) }).islandLayout; diff --git a/packages/desktop-client/src/main/smoke-layout.ts b/packages/desktop-client/src/main/smoke-layout.ts index a6d025a..9f8de49 100644 --- a/packages/desktop-client/src/main/smoke-layout.ts +++ b/packages/desktop-client/src/main/smoke-layout.ts @@ -90,25 +90,21 @@ export async function verifyLayoutSmoke( await pause(); return !island.getEditingLayout() && getConfig().islandLayout.schemes[0]!.rows.length === 2; }); - await check('自定义窗口等于卡片加阴影留白且夹紧工作区', async () => { + await check('自定义岛统一使用固定画布且常态组件居中适配', async () => { await pause(); - const rect = await win.webContents.executeJavaScript( - `(() => {const r=document.querySelector('.custom-card').getBoundingClientRect(), inner=document.querySelector('.custom-inner').getBoundingClientRect();return {width:r.width,height:r.height,contentWidth:inner.width+24,contentHeight:inner.height+24};})()`, - ); - const box = win.getBounds(); - return ( - Math.abs(box.width - rect.width - 2 * ISLAND_SHADOW_PAD) <= 2 && - Math.abs(box.height - rect.height - 2 * ISLAND_SHADOW_PAD) <= 2 && - Math.abs(rect.width - rect.contentWidth) <= 2 && - Math.abs(rect.height - rect.contentHeight) <= 2 - ); + return win.webContents.executeJavaScript(`(() => { + const card=document.querySelector('.resident-card'), source=card?.querySelector('.resident-compact'); + if(!card||!source)return false; + const r=card.getBoundingClientRect(),s=source.getBoundingClientRect(); + return r.width<=innerWidth-${2 * ISLAND_SHADOW_PAD}&&r.height<=innerHeight-${2 * ISLAND_SHADOW_PAD}&&Math.abs(r.left+r.width/2-s.left-s.width/2)<=2; + })()`); }); - await check('自定义提醒复用原渲染器且进入主要行', async () => { + await check('自定义岛紧急通知立即弹出并复用经典通知内容', async () => { island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 }); island.pushNotification({ id: 'custom-smoke-alert', title: '组件化提醒验收', - content: '通知、作业与叫人共用原卡片行为', + content: '通知、作业与叫人共用经典卡片', priority: 'URGENT', createdAt: new Date().toISOString(), teacherName: '临时教师', @@ -116,50 +112,13 @@ export async function verifyLayoutSmoke( }); await pause(); await pause(); - return win.webContents.executeJavaScript( - `(() => {const host=document.querySelector('#custom-alert-host'),node=host?.querySelector('.island-root.embedded');return Boolean(node&&node.textContent.includes('组件化提醒验收')&&host.closest('[data-row]')===document.querySelector('[data-row]'));})()`, - ); - }); - await check('提醒展开内容可见,岛宽取组件与提醒的较宽者', async () => { - const deadline = Date.now() + 4000; - while (Date.now() < deadline) { - const fits = await win.webContents.executeJavaScript(`(() => { - const card=document.querySelector('.custom-card'), host=card?.querySelector('#custom-alert-host'); - const layer=host?.querySelector('.expanded-layer'); - if(!card||!host||!layer||Number(getComputedStyle(layer).opacity)<0.95)return false; - const rows=[...card.querySelectorAll('.row-components')].map(row=>row.getBoundingClientRect().width); - const expected=Math.max(...rows,host.getBoundingClientRect().width)+24; - const outer=card.getBoundingClientRect(), inner=host.getBoundingClientRect(); - return Math.abs(outer.width-expected)<=2 && inner.bottom<=outer.bottom+1; - })()`); - if (fits) return true; - await pause(); - } - return false; - }); - // 隐藏窗口的 rAF 可能限流;先等原渲染器的形变兜底与尺寸防抖落地,再验最终几何。 - await check('组件与提醒同处岛体内部,提醒没有第二层岛壳', async () => { - return win.webContents.executeJavaScript(`(() => { - const card=document.querySelector('.custom-card'), host=card?.querySelector('#custom-alert-host'); - const shape=host?.querySelector('.shape'), content=host?.querySelector('.content'); - if(!card||!host||!shape||!content)return false; - const outer=card.getBoundingClientRect(), inner=host.getBoundingClientRect(); - return document.querySelectorAll('.custom-card').length===1 && getComputedStyle(shape).display==='none' - && getComputedStyle(content).clipPath==='none' && inner.left>=outer.left && inner.right<=outer.right+1 - && inner.top>=outer.top && inner.bottom<=outer.bottom+1 && Boolean(card.querySelector('.clock')); + const result = await win.webContents.executeJavaScript(`(() => { + const card=document.querySelector('.resident-card'), content=card?.querySelector('.classic-notifications'),r=card?.getBoundingClientRect(),c=content?.getBoundingClientRect(); + const valid=Boolean(content&&content.textContent.includes('组件化提醒验收')&&card.querySelectorAll('.shape').length===1&&c.left>=r.left-1&&c.right<=r.right+1&&c.bottom<=r.bottom+1); + return {valid,card:r?.toJSON(),content:c?.toJSON(),shape:card?.querySelectorAll('.shape').length}; })()`); - }); - await check('主要行禁用提醒后按允许提醒的行回落', async () => { - const changed = getConfig().islandLayout; - const rows = changed.schemes[0]!.rows; - rows[0]!.allowNotifications = false; - saveConfig({ islandLayout: changed }); - island.syncCustomLayout(); - await pause(); - await pause(); - return win.webContents.executeJavaScript( - `(() => {const host=document.querySelector('#custom-alert-host');return Boolean(host?.querySelector('.island-root.embedded')&&host.closest('[data-row]')===document.querySelectorAll('[data-row]')[1]);})()`, - ); + if (!result.valid) throw Error(JSON.stringify(result)); + return island.getState().mode === 'expanded'; }); if (process.env.ISLAND_SHOTS_DIR) fs.writeFileSync( diff --git a/packages/desktop-client/src/main/smoke-quick.ts b/packages/desktop-client/src/main/smoke-quick.ts index 0495647..4208868 100644 --- a/packages/desktop-client/src/main/smoke-quick.ts +++ b/packages/desktop-client/src/main/smoke-quick.ts @@ -57,6 +57,7 @@ export async function runQuickSmoke(win: BrowserWindow): Promise { }); for (const [name, method] of [ ['IndexedDB 缓存读写', 'cacheSelfTest'], + ['课表变更和重连立即刷新,慢学期接口与迟到响应不阻塞', 'islandScheduleSyncSelfTest'], ['断网缓存回退', 'offlineScenario'], ['服务端与班级缓存隔离', 'cacheIsolationSelfTest'], ]) { diff --git a/packages/desktop-client/src/main/smoke-resident.ts b/packages/desktop-client/src/main/smoke-resident.ts index eb42c35..4dcd0f2 100644 --- a/packages/desktop-client/src/main/smoke-resident.ts +++ b/packages/desktop-client/src/main/smoke-resident.ts @@ -60,7 +60,7 @@ export async function verifyResidentSmoke( scheme.expandedRows[0]!.nodes = [islandNode('date', 'resident-date'), expandedText]; island.handleAction({ action: 'dismiss-all' }); await pause(); - await check('旧岛配置默认关闭常驻,并兼容补齐空闲展开布局', () => { + await check('旧自定义配置迁移到统一常驻界面,并补齐空闲展开布局', () => { const legacy = JSON.parse(JSON.stringify(fixture)); delete legacy.residentEnabled; delete legacy.schemes[0].expandedRows; @@ -69,7 +69,7 @@ export async function verifyResidentSmoke( Object.assign(oversized.schemes[0], { scale: 99, radius: 99, opacity: -1, spacing: 999 }); const bounds = normalizeIslandLayouts(oversized).schemes[0]!; return ( - !value.residentEnabled && + value.residentEnabled && value.schemes[0]!.expandedRows[0]!.nodes.length > 0 && bounds.scale === 2.5 && bounds.radius === 20 && @@ -164,7 +164,9 @@ export async function verifyResidentSmoke( }); await shot('resident-empty-expanded'); await check('无通知展开页可以收回常驻岛且尺寸恢复', async () => { - await win.webContents.executeJavaScript(`document.querySelector('[aria-label="收起常驻岛"]')?.click()`); + await win.webContents.executeJavaScript( + `document.querySelector('[aria-label="收起自定义灵动岛"]')?.click()`, + ); await pause(800); return ( island.getState().mode === 'hidden' && @@ -187,7 +189,7 @@ export async function verifyResidentSmoke( island.pushNotification(message('resident-normal', '常驻通知一', 'NORMAL')); await pause(); return ( - island.getState().mode === 'pill' && + island.getState().mode === 'hidden' && (await win.webContents.executeJavaScript(`(() => { const card=document.querySelector('.custom-card'),bell=card?.querySelector('.resident-bell'); return Boolean(bell&&!bell.classList.contains('bell-empty')&&bell.textContent.trim()==='1'&&Math.abs(card.getBoundingClientRect().width-${compactWidth})<=2&&!card.classList.contains('has-alert')); @@ -195,18 +197,20 @@ export async function verifyResidentSmoke( ); }); await shot('resident-bell'); - await check('紧急消息也先提示铃铛,重复消息不增加数量', async () => { + await check('紧急消息沿用经典岛立即展开,重复消息不增加数量', async () => { island.pushNotification(message('resident-urgent', '常驻通知二', 'URGENT')); island.pushNotification(message('resident-urgent', '常驻通知二', 'URGENT')); await pause(); return ( - island.getState().mode === 'pill' && + island.getState().mode === 'expanded' && (await win.webContents.executeJavaScript( `document.querySelector('.resident-bell')?.textContent.trim()==='2'`, )) ); }); await check('点击铃铛展开通知列表,上课时也可主动查看', async () => { + island.handleAction({ action: 'collapse' }); + await pause(800); await win.webContents.executeJavaScript(`document.querySelector('.resident-bell')?.click()`); await pause(1800); return ( @@ -219,7 +223,7 @@ export async function verifyResidentSmoke( await shot('resident-notifications'); await check('收起通知保留铃铛,键盘可重新展开', async () => { await win.webContents.executeJavaScript( - `document.querySelector('.resident-panel-heading .icon-btn')?.click()`, + `document.querySelector('.classic-notifications .icon-btn')?.click()`, ); await pause(800); const collapsed = island.getState().mode === 'pill'; @@ -253,11 +257,58 @@ export async function verifyResidentSmoke( await pause(); island.pushNotification(message('resident-after-open', '展开后新消息', 'NORMAL')); await pause(800); - const collapsed = island.getState().mode === 'pill'; + const collapsed = island.getState().mode === 'hidden'; island.handleAction({ action: 'dismiss-all' }); await pause(); return collapsed; }); + await check('课表变更立即传到常态与展开组件,重新加载后仍恢复最新快照', async () => { + const updated = [ + { + id: 'island-schedule-live', + classId: 'local', + courseId: 'local-course', + dayOfWeek: new Date().getDay() || 7, + startTime: '00:00', + endTime: '23:59', + location: null, + weekStart: 1, + weekEnd: 40, + weekParity: 'ALL' as const, + createdAt: new Date().toISOString(), + course: { id: 'local-course', name: '实时课程' }, + }, + ]; + const settings = JSON.parse(JSON.stringify(fixture)); + settings.schemes[0].rows[0].nodes = [islandNode('schedule', 'live-schedule')]; + saveConfig({ islandLayout: settings }); + island.syncCustomLayout(); + await pause(); + const started = Date.now(); + island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1, schedules: updated }); + const waitForCourse = async () => { + for (let i = 0; i < 30; i++) { + if ( + await win.webContents.executeJavaScript( + `document.querySelector('.resident-compact .schedule')?.textContent.includes('实时课程')`, + ) + ) + return true; + await pause(20); + } + return false; + }; + const immediate = (await waitForCourse()) && Date.now() - started < 600; + await win.webContents.reload(); + await pause(800); + const restored = await waitForCourse(); + const snapshot = await win.webContents.executeJavaScript('window.island.getSchedules()'); + saveConfig({ islandLayout: fixture }); + island.syncCustomLayout(); + await pause(800); + island.setClassState({ inClass: true, currentPeriodEnd: null, week: 1, schedules: [] }); + return immediate && restored && snapshot[0]?.course?.name === '实时课程'; + }); await check('常驻岛开合复用固定画布,动画中没有滚动容器或旧胶囊形变', async () => { const bounds = new Set(); const timer = setInterval(() => bounds.add(JSON.stringify(win.getBounds())), 20); @@ -365,8 +416,8 @@ export async function verifyResidentSmoke( island.handleAction({ action: 'expand-list' }); await pause(800); const geometry = await win.webContents.executeJavaScript(`(() => { - const card=document.querySelector('.resident-card'), footer=card.querySelector('.resident-actions'), r=card.getBoundingClientRect(), f=footer?.getBoundingClientRect(); - return {r:r.toJSON(),f:f?.toJSON(), fits:Boolean(f&&f.bottom<=r.bottom+1&&f.left>=r.left&&f.right<=r.right+1&&card.querySelector('.resident-more')&&[card,...card.querySelectorAll('*')].every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)))}; + const card=document.querySelector('.resident-card'), footer=card.querySelector('.classic-notifications .foot'), r=card.getBoundingClientRect(), f=footer?.getBoundingClientRect(); + return {r:r.toJSON(),f:f?.toJSON(), fits:Boolean(f&&f.bottom<=r.bottom+1&&f.left>=r.left&&f.right<=r.right+1&&(card.querySelector('.list-hint')||card.querySelectorAll('.list-row').length===8)&&[card,...card.querySelectorAll('*')].every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)))}; })()`); await shot('resident-many-notifications'); island.handleAction({ action: 'dismiss-all' }); diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts index d017389..5a323cd 100644 --- a/packages/desktop-client/src/preload/island.ts +++ b/packages/desktop-client/src/preload/island.ts @@ -21,7 +21,8 @@ export interface IslandBridge { reportSize(value: { width: number; height: number }): void; reportResidentControl(value: { x: number; y: number; width: number; height: number } | null): void; noteResidentTouch(): void; - onViewport(handler: (value: { width: number; height: number }) => void): void; + onViewport(handler: (value: { width: number; height: number }) => void): () => void; + getSchedules(): Promise; onSchedules(handler: (value: ContractScheduleDto[]) => void): void; onState(handler: (state: IslandState) => void): void; @@ -74,8 +75,12 @@ const bridge: IslandBridge = { reportResidentControl: (value) => ipcRenderer.send('island:resident-control', value), noteResidentTouch: () => ipcRenderer.send('island:resident-touch'), onViewport: (handler) => { - ipcRenderer.on('island:viewport', (_event, value) => handler(value)); + const listener = (_event: Electron.IpcRendererEvent, value: { width: number; height: number }) => + handler(value); + ipcRenderer.on('island:viewport', listener); + return () => ipcRenderer.removeListener('island:viewport', listener); }, + getSchedules: () => ipcRenderer.invoke('island:get-schedules'), onSchedules: (handler) => { ipcRenderer.on('island:schedules', (_event, value) => handler(value)); }, diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index 2a7da29..3cf45fd 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -1192,9 +1192,121 @@ export async function aboutPageSelfTest(): Promise { } } +/** 本地替换 API,不连接真实服务:覆盖变更推送、重连与迟到响应。 */ +export async function islandScheduleSyncSelfTest(): Promise { + const [{ api }, { useAuthStore }, { useAppStore }, { useRealtimeStore }, bridge, { SOCKET_EVENTS }] = + await Promise.all([ + import('../api/http.js'), + import('../stores/auth.js'), + import('../stores/app.js'), + import('../stores/realtime.js'), + import('../island/bridge.js'), + import('@classhelper/shared'), + ]); + const auth = useAuthStore(), + app = useAppStore(), + realtime = useRealtimeStore(); + const token = auth.token, + week = app.currentWeek, + originalGet = api.get, + originalOn = realtime.on; + const callbacks = new Map void>(); + const terms: Array<(value: unknown) => void> = []; + let name = '变更后课程', + calls = 0, + delay = false, + release: (() => void) | undefined; + const snapshot = (courseName: string) => ({ + days: [ + { + schedules: [ + { + id: 'local-live-course', + classId: 'local', + courseId: 'local-course', + dayOfWeek: new Date().getDay() || 7, + startTime: '00:00', + endTime: '23:59', + weekStart: 1, + weekEnd: 40, + weekParity: 'ALL', + course: { id: 'local-course', name: courseName }, + }, + ], + }, + ], + }); + bridge.stopIslandBridge(); + const key = `week:${week}`, + cached = await cacheGet('schedules', key); + try { + auth.applyToken('local-smoke-schedule'); + api.get = (url: string): Promise => { + if (url === '/schedules/active-week') { + calls++; + const data = snapshot(name); + if (delay) { + delay = false; + return new Promise((resolve) => { + release = () => resolve(data as T); + }); + } + return Promise.resolve(data as T); + } + // 模拟很慢的学期接口,不能再挡住课表推送。 + return new Promise((resolve) => terms.push((value) => resolve(value as T))); + }; + realtime.on = (event, handler) => { + callbacks.set(event, handler); + originalOn(event, handler); + }; + bridge.startIslandBridge(); + const wait = async (expected: string) => { + const deadline = performance.now() + 700; + while (performance.now() < deadline) { + if (bridge.getIslandSchedules()[0]?.course?.name === expected) return true; + await new Promise((r) => setTimeout(r, 10)); + } + return false; + }; + const started = performance.now(); + callbacks.get(SOCKET_EVENTS.scheduleUpdated)?.({}); + const changed = await wait(name), + elapsed = performance.now() - started; + name = '断线重连课程'; + callbacks.get('__connected__')?.({}); + const reconnected = await wait(name); + name = '迟到的旧课程'; + delay = true; + const old = bridge.refreshIslandSchedules(); + await new Promise((r) => setTimeout(r, 10)); + name = '最新课程'; + await bridge.refreshIslandSchedules(); + release?.(); + await old; + const fresh = bridge.getIslandSchedules()[0]?.course?.name === name; + const saved = await cacheGet>('schedules', key); + const cacheFresh = saved?.value[0]?.course?.name === name; + return { + ok: changed && reconnected && fresh && cacheFresh && calls === 5, + detail: `本地推送刷新 ${Math.round(elapsed)}ms,重连=${reconnected},迟到响应隔离=${fresh && cacheFresh},课表请求=${calls}`, + }; + } finally { + bridge.stopIslandBridge(); + api.get = originalGet; + realtime.on = originalOn; + for (const resolve of terms) resolve({ currentWeek: week, maxWeek: 40 }); + auth.applyToken(token); + app.currentWeek = week; + if (cached) await cacheSet('schedules', key, cached.value); + else await cacheRemove('schedules', key); + } +} + /** 注册到 window,供主进程冒烟脚本调用 */ export function registerSmokeHooks(): void { window.__classhelperSmoke__ = { + islandScheduleSyncSelfTest, cacheSelfTest, cacheIsolationSelfTest, offlineScenario, diff --git a/packages/desktop-client/src/renderer/island/bridge.ts b/packages/desktop-client/src/renderer/island/bridge.ts index c2b3f69..b47612d 100644 --- a/packages/desktop-client/src/renderer/island/bridge.ts +++ b/packages/desktop-client/src/renderer/island/bridge.ts @@ -23,11 +23,11 @@ import { useRealtimeStore } from '../stores/realtime.js'; * 职责:把"当前是否在上课时间段"这件事算出来并同步给主进程 —— * 主进程据此决定通知是"立即弹出"还是"上课期间隐藏、下课后自动弹出"。 * - * 判定完全基于本地缓存的课表(离线也能算),并每 10 秒复算一次, - * 因此下课后最多 10 秒内会自动弹出暂存的通知。 + * 判定完全基于本地缓存的课表(离线也能算),并每秒复算一次, + * 因此下课后最多 1 秒内会自动弹出暂存的通知。 */ -const TICK_INTERVAL_MS = 10_000; -const REFRESH_INTERVAL_MS = 5 * 60_000; +const TICK_INTERVAL_MS = 1_000; +const REFRESH_INTERVAL_MS = 30_000; let schedules: ScheduleDto[] = []; let currentPeriod: ClassPeriod | null = null; @@ -54,27 +54,38 @@ export async function refreshIslandSchedules(): Promise { if (!auth.token) return; const scope = getCacheScope(); const classId = auth.classId ?? undefined; - await appStore.refreshTerm(classId); - if (scope !== getCacheScope() || !auth.token) return; const request = ++scheduleRequest; + if (scope !== getCacheScope() || !auth.token || request !== scheduleRequest) return; const week = appStore.currentWeek; const result = await fetchWithCache( 'schedules', `week:${week}`, - async () => - ( + async () => { + const data = ( await api.get('/schedules/active-week', { classId, week }) - ).days.flatMap((day) => day.schedules), + ).days.flatMap((day) => day.schedules); + // 网络结果先上岛,IndexedDB 写入不再挡住可见内容;旧请求与切换账号的结果不得回写。 + if (scope !== getCacheScope() || request !== scheduleRequest || !auth.token) + throw new Error('课表刷新已被新请求替代'); + schedules = data; + scheduleWeek = week; + recomputeClassState(true); + return data; + }, [], ); if (scope !== getCacheScope() || request !== scheduleRequest) return; if (result.error && !result.updatedAt) return; - schedules = result.data; - scheduleWeek = week; - recomputeClassState(true); + if (result.fromCache) { + schedules = result.data; + scheduleWeek = week; + recomputeClassState(true); + } } +let sentDay = ''; function recomputeClassState(force = false): void { + const day = new Date().toDateString(); const appStore = useAppStore(); const status = resolveClassStatus(schedules, new Date(), scheduleWeek); const nextInClass = status.inClass; @@ -83,7 +94,8 @@ function recomputeClassState(force = false): void { currentPeriod?.endTime === status.current?.endTime; currentPeriod = status.current; - if (!force && nextInClass === inClass && samePeriod) return; + if (!force && day === sentDay && nextInClass === inClass && samePeriod) return; + sentDay = day; inClass = nextInClass; window.desktop?.islandSetClassState({ @@ -329,6 +341,10 @@ export function subscribeIslandMarkAllRead(): void { /** 具名的课表变更处理器:匿名函数在停止时无法 off 掉,会随"登出→登录"越挂越多 */ function onScheduleUpdated(): void { + // 教学周校正并行进行,课表变更不再等待另一个接口;周次改变由 watch 补取。 + void useAppStore() + .refreshTerm(useAuthStore().classId ?? undefined) + .catch(() => undefined); void refreshIslandSchedules().catch(() => undefined); } @@ -341,14 +357,15 @@ export function startIslandBridge(): void { }, ); - void refreshIslandSchedules().catch(() => undefined); + onScheduleUpdated(); + useRealtimeStore().on('__connected__', onScheduleUpdated); // 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏" useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated); tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS); refreshTimer = setInterval(() => { - void refreshIslandSchedules().catch(() => undefined); + onScheduleUpdated(); }, REFRESH_INTERVAL_MS); } @@ -370,6 +387,8 @@ export function stopIslandBridge(): void { stopWeekWatch = null; useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated); + useRealtimeStore().off('__connected__', onScheduleUpdated); + sentDay = ''; schedules = []; currentPeriod = null; inClass = false; @@ -381,5 +400,6 @@ export function stopIslandBridge(): void { inClass: false, currentPeriodEnd: null, week: useAppStore().currentWeek, + schedules: [], }); } diff --git a/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue index 15468b8..75ece7e 100644 --- a/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue +++ b/packages/desktop-client/src/renderer/views/settings/IslandSettingsView.vue @@ -44,12 +44,7 @@ async function changeLayout() { } async function chooseLayout(mode: 'fixed' | 'custom') { layoutMode.value = mode; - if (mode === 'fixed') residentEnabled.value = false; - await changeLayout(); -} -async function changeResident(value: boolean | string | number) { - residentEnabled.value = value === true; - if (residentEnabled.value) layoutMode.value = 'custom'; + residentEnabled.value = mode === 'custom'; await changeLayout(); } async function changePointerFade( @@ -152,7 +147,7 @@ onMounted(async () => { 灵动岛 - 屏幕上方的消息浮窗:外观、停靠位置与动画 + 经典岛与自定义岛共用外观、停靠位置、动画与提醒规则 @@ -175,36 +170,20 @@ onMounted(async () => { @click="chooseLayout('custom')" > 自定义灵动岛 - 在岛内排列时钟、课程和消息等组件 + 常驻显示时钟、课程等组件,通知沿用经典岛 - - - 常驻岛 - 始终显示自定义内容;收到消息后显示铃铛,点击查看。没有通知时可展开另一套自定义内容。 - - - 按你的习惯布置岛内内容 - - {{ - residentEnabled - ? '分别编辑「收起时」与「无通知展开时」的内容,点选组件即可设置。' - : '在预览中添加和排列组件,点选组件即可设置;更改自动保存到本机。' - }} - + 编辑常态内容与无通知展开内容;通知样式、弹出规则和外观设置与经典岛共用。 编辑岛内组件 - + - ClassIsland 窗口设置 - - 常驻行基准高度 40 px。指针移入背景后淡化至 5% - 并穿透,右侧铃铛或展开按钮保持可用;编辑、展开和触摸交互期间暂停避让。 - + 指针避让 + 指针移入后淡化并穿透,右侧展开入口保持可用;展开和触摸交互期间暂停避让。 指针移入淡化 @@ -225,87 +204,86 @@ onMounted(async () => { - {{ layoutMode === 'fixed' ? '外观与位置' : '位置与提醒' }} + 外观、位置与弹出 - - - - applyIslandAppearance({ height: value })" + 紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。 + + + applyIslandAppearance({ height: value })" + /> + + + applyIslandAppearance({ width: value })" + /> + + + applyIslandAppearance({ radius: value })" + /> + + + applyIslandAppearance({ opacity: value })" + /> + + + applyIslandAppearance({ fontSize: value })" + /> + + + applyIslandAppearance({ speed: value })" + /> + + + applyIslandAppearance({ accent: value ?? '#0a84ff' })" + /> + 用于高亮、按钮与进度条 + + + applyIslandAppearance({ style: value })" + > + - - - applyIslandAppearance({ width: value })" - /> - - - applyIslandAppearance({ radius: value })" - /> - - - applyIslandAppearance({ opacity: value })" - /> - - - applyIslandAppearance({ fontSize: value })" - /> - - - applyIslandAppearance({ speed: value })" - /> - - - applyIslandAppearance({ accent: value ?? '#0a84ff' })" - /> - 用于高亮、按钮与进度条 - - - applyIslandAppearance({ style: value })" - > - - - 纯黑 / 毛玻璃(半透明)/ 主题色渐变 - - + + 纯黑 / 毛玻璃(半透明)/ 主题色渐变 + ; cacheSelfTest(): Promise<{ ok: boolean; detail: string }>; cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>; offlineScenario(): Promise<{ ok: boolean; detail: string }>; diff --git a/packages/shared/src/island-layout.ts b/packages/shared/src/island-layout.ts index 9113963..cb485a6 100644 --- a/packages/shared/src/island-layout.ts +++ b/packages/shared/src/island-layout.ts @@ -214,7 +214,7 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings { const fallback = defaultIslandLayouts(); if (!input || typeof input !== 'object') return fallback; const raw = input as IslandLayoutSettings; - const resident = raw.layout === 'custom' && raw.residentEnabled === true; + const resident = raw.layout === 'custom'; const clamp = (n: unknown, min: number, max: number, value: number) => typeof n === 'number' && Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : value; const color = (v: unknown, value: string) => @@ -327,7 +327,8 @@ export function normalizeIslandLayouts(input: unknown): IslandLayoutSettings { } return { layout: raw.layout === 'custom' ? 'custom' : 'fixed', - residentEnabled: raw.layout === 'custom' && raw.residentEnabled === true, + // 旧常驻开关保留为兼容字段;自定义布局统一常驻,不再分成两种运行模式。 + residentEnabled: raw.layout === 'custom', pointerFadeEnabled: raw.pointerFadeEnabled !== false, pointerFadeReversed: raw.pointerFadeReversed === true, schemes: schemes.length ? schemes : fallback.schemes,
{{ notification?.content ?? '' }}
{{ callHint }}
{{ notification?.subtitle }}
- 开启后始终显示收起内容,收到消息时显示铃铛,点击查看通知;没有通知时展开另一套自定义内容。 + 通知、外观与弹出动画沿用经典灵动岛。在应用的灵动岛设置中调整颜色、字号、圆角和动画速度。
- 基准岛高 40 px,默认缩放 1×;正文 16、时钟 18 px。背景移入淡化并穿透,右侧展开按钮保持可用。 -
屏幕上方的消息浮窗:外观、停靠位置与动画
经典岛与自定义岛共用外观、停靠位置、动画与提醒规则
始终显示自定义内容;收到消息后显示铃铛,点击查看。没有通知时可展开另一套自定义内容。
- {{ - residentEnabled - ? '分别编辑「收起时」与「无通知展开时」的内容,点选组件即可设置。' - : '在预览中添加和排列组件,点选组件即可设置;更改自动保存到本机。' - }} -
编辑常态内容与无通知展开内容;通知样式、弹出规则和外观设置与经典岛共用。
- 常驻行基准高度 40 px。指针移入背景后淡化至 5% - 并穿透,右侧铃铛或展开按钮保持可用;编辑、展开和触摸交互期间暂停避让。 -
指针移入后淡化并穿透,右侧展开入口保持可用;展开和触摸交互期间暂停避让。
紧急消息立即展开;课中普通通知暂存,下课后展开。自定义岛可点击铃铛主动查看。