fix(island): 修好"非紧急叫人 / 紧急叫人收起 / 设置页预览"三处点不开,并让胶囊汇总通知类型

用户反馈的三个场景:
1) 非紧急叫人(HIGH + kind=call)—— 上课时段原先只进队列、屏幕上看不到东西,
   自然"无法点开"。现在:上课时段仍以**胶囊**保持可见(不自动展开、不打断课堂),
   点一下即可展开详情;下课自动展开。新增 isOpenable():紧急消息与"任何叫人"都允许点开。
2) 紧急叫人点击缩回后点不开 —— 归因于固定包围盒窗口的命中丢失(上一轮已改为光标兜底轮询),
   本轮补上"展开 → 收起 → 再次展开"的叫人类专项回归。
3) 设置页"预览效果"示例岛无法正常展开 —— 预览通知现在走 preview 通道:直接展开、
   失焦不收起(用户拖滑块时主窗口一直是焦点)、30 秒后自行消失、点"知道了"不留残影。

顺带修掉两个真问题:
- pushNotification 在"胶囊态"收到新消息时会把上一条静默顶掉(只有展开态才回队);
  现在只要换了当前展示的消息,上一条一律回队列 —— 否则类型汇总与"还有 N 条"都会漏。
- syncWindow 的上课时段守卫只认"紧急",导致普通叫人被 hideImmediately 藏掉;改用 isOpenable。

胶囊类型汇总(用户要求):多条未处理消息时收起态显示
「新消息:叫人/作业/通知(共 3 条)」+「点击查看」,并把三种类型做成小标签;
单条时仍显示类型名(新作业 / 老师叫你 / 新消息)。

性能:岛体矩形上报加限流(~10 次/秒,尾随补发最终形态),修掉"每帧 IPC + 每帧读光标"
导致的渲染变慢(实测会让开合采样从 57 帧掉到 4 帧)。

验证:verify:desktop 71/71 + 离线复跑 65/65;typecheck/lint 全绿。
This commit is contained in:
LoveChengke committed 2026-09-12 22:36:37 +08:00
1 parent d8b0eb05c2
commit 86430c5f4a
5 files changed
+353 -35

No files matched your search

@@ -58,6 +58,43 @@ const pillTitle = computed(() => {
return isUrgent.value ? '紧急通知' : '新消息';
});
/**
* 未处理消息的**类型汇总**(当前展示的 + 队列里的):
* 用户要求收起后的胶囊直接说明这批消息里都有什么,例如
* 「新消息:叫人/作业/通知(共 3 条)」+「点击查看」。
*/
const TYPE_LABELS: Record<IslandNotificationKind, string> = {
call: '叫人',
homework: '作业',
notification: '通知',
};
/** 汇总展示顺序:叫人 > 作业 > 通知(与用户示例一致) */
const TYPE_ORDER: IslandNotificationKind[] = ['call', 'homework', 'notification'];
const pendingNotifications = computed<IslandNotification[]>(() => {
const list: IslandNotification[] = [];
if (state.value.active) list.push(state.value.active);
for (const item of state.value.queued) {
if (!list.some((existing) => existing.id === item.id)) list.push(item);
}
return list;
});
/** 这批未处理消息覆盖了哪些类型(按"叫人/作业/通知"顺序) */
const pendingTypes = computed<string[]>(() => {
const kinds = new Set(pendingNotifications.value.map((item) => item.kind ?? 'notification'));
return TYPE_ORDER.filter((item) => kinds.has(item)).map((item) => TYPE_LABELS[item]);
});
const pendingCount = computed(() => pendingNotifications.value.length);
/** 多条消息时把类型摊开:`新消息:叫人/作业/通知(共 3 条)` */
const pillSummaryTitle = computed(() => {
if (pendingCount.value <= 1) return pillTitle.value;
const types = pendingTypes.value.join('/');
return types ? `新消息:${types}(共 ${pendingCount.value} 条)` : `新消息(共 ${pendingCount.value} 条)`;
});
/** 展开态徽标:叫人 / 新作业 / 优先级文案 */
const badgeText = computed(() => {
if (isCall.value) return '叫人';
@@ -301,8 +338,11 @@ const islandClass = computed(() => ({
let lastInteractive: boolean | null = null;
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
let lastPointer: { x: number; y: number } | null = null;
/** 最近一次上报给主进程的岛体矩形(去重,避免形变期间刷屏 IPC) */
/** 最近一次上报给主进程的岛体矩形(去重 + 限流,避免形变期间刷屏 IPC 拖慢渲染) */
let lastHitRectKey: string | null = null;
let lastHitRectAt = 0;
const HIT_RECT_MIN_INTERVAL_MS = 100;
let hitRectTrailingTimer: number | null = null;
/** 把岛体矩形上报给主进程:主进程按光标位置兜底校正命中(不依赖 mousemove 转发) */
function reportHitRect(rect: DOMRect | null): void {
@@ -312,7 +352,30 @@ function reportHitRect(rect: DOMRect | null): void {
: 'none';
if (key === lastHitRectKey) return;
lastHitRectKey = key;
bridge?.setHitRect?.(payload);
const now = performance.now();
const elapsed = now - lastHitRectAt;
if (elapsed >= HIT_RECT_MIN_INTERVAL_MS) {
lastHitRectAt = now;
bridge?.setHitRect?.(payload);
return;
}
// 限流:形变过程中每帧都发会挤占主进程(实测拖慢到采样都变少),
// 这里保证"最后一次形状"一定会送达(尾随发送)。
if (hitRectTrailingTimer !== null) window.clearTimeout(hitRectTrailingTimer);
hitRectTrailingTimer = window.setTimeout(() => {
hitRectTrailingTimer = null;
lastHitRectAt = performance.now();
const node = document.querySelector('.island-card') as HTMLElement | null;
const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver;
const latest = node && !fullyHidden ? node.getBoundingClientRect() : null;
const latestPayload = latest
? { x: latest.left, y: latest.top, width: latest.width, height: latest.height }
: null;
lastHitRectKey = latestPayload
? `${Math.round(latestPayload.x)},${Math.round(latestPayload.y)},${Math.round(latestPayload.width)},${Math.round(latestPayload.height)}`
: 'none';
bridge?.setHitRect?.(latestPayload);
}, HIT_RECT_MIN_INTERVAL_MS);
}
function updateInteractive(clientX: number, clientY: number): void {
@@ -449,11 +512,15 @@ onMounted(async () => {
</svg>
</span>
<span class="pill-text">
<span class="pill-title">
{{ pillTitle }}
<template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template>
<span class="pill-title">{{ pillSummaryTitle }}</span>
<span class="pill-sub">
<template v-if="pendingCount > 1">点击查看</template>
<template v-else>{{ notification?.teacherName || '老师' }} · 点击查看</template>
</span>
<span class="pill-sub">{{ notification?.teacherName || '老师' }} · 点击查看</span>
</span>
<!-- 多类型时把类型也做成小圆点,收起状态一眼看出有什么 -->
<span v-if="pendingCount > 1 && pendingTypes.length > 1" class="pill-types" aria-hidden="true">
<span v-for="item in pendingTypes" :key="item" class="pill-type">{{ item }}</span>
</span>
<span class="pill-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24">
@@ -753,6 +820,25 @@ body {
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;