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:
1 parent
d8b0eb05c2
commit
86430c5f4a
5 files changed
+348
-30
No files matched your search
@@ -58,6 +58,43 @@ const pillTitle = computed(() => {
|
|||||||
return isUrgent.value ? '紧急通知' : '新消息';
|
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(() => {
|
const badgeText = computed(() => {
|
||||||
if (isCall.value) return '叫人';
|
if (isCall.value) return '叫人';
|
||||||
@@ -301,8 +338,11 @@ const islandClass = computed(() => ({
|
|||||||
let lastInteractive: boolean | null = null;
|
let lastInteractive: boolean | null = null;
|
||||||
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
|
/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */
|
||||||
let lastPointer: { x: number; y: number } | null = null;
|
let lastPointer: { x: number; y: number } | null = null;
|
||||||
/** 最近一次上报给主进程的岛体矩形(去重,避免形变期间刷屏 IPC) */
|
/** 最近一次上报给主进程的岛体矩形(去重 + 限流,避免形变期间刷屏 IPC 拖慢渲染) */
|
||||||
let lastHitRectKey: string | null = null;
|
let lastHitRectKey: string | null = null;
|
||||||
|
let lastHitRectAt = 0;
|
||||||
|
const HIT_RECT_MIN_INTERVAL_MS = 100;
|
||||||
|
let hitRectTrailingTimer: number | null = null;
|
||||||
|
|
||||||
/** 把岛体矩形上报给主进程:主进程按光标位置兜底校正命中(不依赖 mousemove 转发) */
|
/** 把岛体矩形上报给主进程:主进程按光标位置兜底校正命中(不依赖 mousemove 转发) */
|
||||||
function reportHitRect(rect: DOMRect | null): void {
|
function reportHitRect(rect: DOMRect | null): void {
|
||||||
@@ -312,7 +352,30 @@ function reportHitRect(rect: DOMRect | null): void {
|
|||||||
: 'none';
|
: 'none';
|
||||||
if (key === lastHitRectKey) return;
|
if (key === lastHitRectKey) return;
|
||||||
lastHitRectKey = key;
|
lastHitRectKey = key;
|
||||||
|
const now = performance.now();
|
||||||
|
const elapsed = now - lastHitRectAt;
|
||||||
|
if (elapsed >= HIT_RECT_MIN_INTERVAL_MS) {
|
||||||
|
lastHitRectAt = now;
|
||||||
bridge?.setHitRect?.(payload);
|
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 {
|
function updateInteractive(clientX: number, clientY: number): void {
|
||||||
@@ -449,11 +512,15 @@ onMounted(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="pill-text">
|
<span class="pill-text">
|
||||||
<span class="pill-title">
|
<span class="pill-title">{{ pillSummaryTitle }}</span>
|
||||||
{{ pillTitle }}
|
<span class="pill-sub">
|
||||||
<template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template>
|
<template v-if="pendingCount > 1">点击查看</template>
|
||||||
|
<template v-else>{{ notification?.teacherName || '老师' }} · 点击查看</template>
|
||||||
</span>
|
</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>
|
||||||
<span class="pill-chevron" aria-hidden="true">
|
<span class="pill-chevron" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
@@ -753,6 +820,25 @@ body {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.pill-chevron {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export interface IslandPushContext {
|
|||||||
inClass?: boolean;
|
inClass?: boolean;
|
||||||
currentPeriodEnd?: string | null;
|
currentPeriodEnd?: string | null;
|
||||||
week?: number;
|
week?: number;
|
||||||
|
/** 设置页"预览效果":直接展开示例岛、失焦不收起、到点自行消失 */
|
||||||
|
preview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IslandClassStatePayload {
|
export interface IslandClassStatePayload {
|
||||||
@@ -131,6 +133,12 @@ class IslandController {
|
|||||||
private hitTimer: ReturnType<typeof setInterval> | null = null;
|
private hitTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
/** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */
|
/** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */
|
||||||
private cursorOverride: { x: number; y: number } | null = null;
|
private cursorOverride: { x: number; y: number } | null = null;
|
||||||
|
/** 上次按矩形立即校正命中的时刻(限流用) */
|
||||||
|
private lastHitSyncAt = 0;
|
||||||
|
/** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */
|
||||||
|
private previewId: string | null = null;
|
||||||
|
/** 预览示例岛的自动收尾定时器 */
|
||||||
|
private previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
private state: IslandState = {
|
private state: IslandState = {
|
||||||
mode: 'hidden',
|
mode: 'hidden',
|
||||||
@@ -218,6 +226,8 @@ class IslandController {
|
|||||||
*/
|
*/
|
||||||
win.on('blur', () => {
|
win.on('blur', () => {
|
||||||
if (this.state.mode !== 'expanded' || this.state.inClass) return;
|
if (this.state.mode !== 'expanded' || this.state.inClass) return;
|
||||||
|
// 设置页预览:示例岛要继续留在屏幕上(用户在调滑块时主窗口一直是焦点)
|
||||||
|
if (this.previewId && this.state.active?.id === this.previewId) return;
|
||||||
const sinceActivated = Date.now() - this.state.updatedAt;
|
const sinceActivated = Date.now() - this.state.updatedAt;
|
||||||
if (sinceActivated < BLUR_GRACE_MS) {
|
if (sinceActivated < BLUR_GRACE_MS) {
|
||||||
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
|
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
|
||||||
@@ -280,10 +290,22 @@ class IslandController {
|
|||||||
return notification.priority === 'URGENT';
|
return notification.priority === 'URGENT';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否"允许点开"(上课时段也允许):
|
||||||
|
* - 紧急消息(含紧急叫人):必须立刻看到;
|
||||||
|
* - **任何"叫人"**:老师在等学生,允许学生主动点开看(但普通叫人不会自动展开打断课堂)。
|
||||||
|
* 其它普通通知在上课时段一律不显示、也不可点开。
|
||||||
|
*/
|
||||||
|
private isOpenable(notification: IslandNotification | null | undefined): boolean {
|
||||||
|
if (!notification) return false;
|
||||||
|
return this.isImmediate(notification) || notification.kind === 'call';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收到一条消息。
|
* 收到一条消息。
|
||||||
* - 紧急(含紧急叫人):立刻展开(无视上课时段)
|
* - 紧急(含紧急叫人):立刻展开(无视上课时段)
|
||||||
* - 上课中:进队列并保持隐藏(下课后由 setClassState 触发弹出)
|
* - 预览(设置页"预览效果"):直接展开,且不因失焦收起
|
||||||
|
* - 上课中:普通通知进队列并保持隐藏;**叫人**仍以胶囊形式可见(可点开、不自动展开)
|
||||||
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
|
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
|
||||||
*/
|
*/
|
||||||
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
|
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
|
||||||
@@ -292,6 +314,23 @@ class IslandController {
|
|||||||
if (context.currentPeriodEnd !== undefined)
|
if (context.currentPeriodEnd !== undefined)
|
||||||
this.state.currentPeriodEnd = context.currentPeriodEnd ?? null;
|
this.state.currentPeriodEnd = context.currentPeriodEnd ?? null;
|
||||||
|
|
||||||
|
// 设置页"预览效果":直接把示例岛展开(并标记为预览,失焦不收起、超时后自行消失)
|
||||||
|
if (context.preview) {
|
||||||
|
this.previewId = notification.id;
|
||||||
|
this.clearTimers();
|
||||||
|
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
|
||||||
|
this.state.active = notification;
|
||||||
|
this.state.updatedAt = Date.now();
|
||||||
|
this.setState({ mode: 'expanded' });
|
||||||
|
this.previewTimer = setTimeout(() => {
|
||||||
|
this.previewTimer = null;
|
||||||
|
this.endPreview(notification.id);
|
||||||
|
}, PREVIEW_DURATION_MS);
|
||||||
|
logger.info(`灵动岛预览:${notification.title}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.previewId && this.previewId !== notification.id) this.endPreview(this.previewId);
|
||||||
|
|
||||||
if (this.isImmediate(notification)) {
|
if (this.isImmediate(notification)) {
|
||||||
// 插播:如果正在展示别的消息,把它放回队列
|
// 插播:如果正在展示别的消息,把它放回队列
|
||||||
if (this.state.active && this.state.active.id !== notification.id) {
|
if (this.state.active && this.state.active.id !== notification.id) {
|
||||||
@@ -308,6 +347,16 @@ class IslandController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (inClass && notification.kind === 'call') {
|
||||||
|
// 上课时段的**普通叫人**:不自动展开(不打断课堂),但保留胶囊让学生能主动点开
|
||||||
|
if (this.state.active && this.state.active.id !== notification.id) {
|
||||||
|
this.state.queued.push(this.state.active);
|
||||||
|
}
|
||||||
|
this.activate(notification, 'call', 'pill');
|
||||||
|
logger.info(`灵动岛叫人消息(上课中,保持胶囊可点开):${notification.title}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (inClass) {
|
if (inClass) {
|
||||||
// 上课时段:自动隐藏,暂存待下课后弹出
|
// 上课时段:自动隐藏,暂存待下课后弹出
|
||||||
this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification];
|
this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification];
|
||||||
@@ -320,10 +369,13 @@ class IslandController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.mode === 'expanded' && this.state.active) {
|
// 展示新的一条之前,把"正在展示的上一条"放回队列(否则它会被静默顶掉,
|
||||||
// 已经展开时,把当前这条挤入队列并显示新的一条
|
// 收起后的类型汇总与"还有 N 条"都会漏掉它)
|
||||||
|
if (this.state.active && this.state.active.id !== notification.id) {
|
||||||
|
if (!this.state.queued.some((item) => item.id === this.state.active?.id)) {
|
||||||
this.state.queued.push(this.state.active);
|
this.state.queued.push(this.state.active);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification];
|
this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification];
|
||||||
this.activate(notification, 'new', 'pill');
|
this.activate(notification, 'new', 'pill');
|
||||||
}
|
}
|
||||||
@@ -348,9 +400,9 @@ class IslandController {
|
|||||||
|
|
||||||
if (wasInClass && !payload.inClass) {
|
if (wasInClass && !payload.inClass) {
|
||||||
const active = this.state.active;
|
const active = this.state.active;
|
||||||
if (active && this.isImmediate(active) && this.state.mode === 'pill') {
|
if (active && this.isOpenable(active) && this.state.mode === 'pill') {
|
||||||
// 上课期间被收起的紧急消息(含紧急叫人):下课后自动展开,避免学生错过
|
// 上课期间被收起的紧急消息 / 叫人:下课后自动展开,避免学生错过
|
||||||
logger.info('灵动岛:下课,重新展开被收起的紧急消息');
|
logger.info('灵动岛:下课,重新展开被收起的紧急消息 / 叫人');
|
||||||
this.setState({ mode: 'expanded' });
|
this.setState({ mode: 'expanded' });
|
||||||
this.scheduleCollapse(active.kind === 'call' ? TIMEOUTS.call : TIMEOUTS.urgent);
|
this.scheduleCollapse(active.kind === 'call' ? TIMEOUTS.call : TIMEOUTS.urgent);
|
||||||
return;
|
return;
|
||||||
@@ -382,7 +434,7 @@ class IslandController {
|
|||||||
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
||||||
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
||||||
const showing = this.state.active;
|
const showing = this.state.active;
|
||||||
if (this.state.inClass && !(showing && this.isImmediate(showing))) break;
|
if (this.state.inClass && !this.isOpenable(showing)) break;
|
||||||
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
|
// 收起态(胶囊 / 空闲细缝)再次点击必须能打开:
|
||||||
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
|
// active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开,
|
||||||
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
|
// 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。
|
||||||
@@ -409,8 +461,8 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
case 'collapse':
|
case 'collapse':
|
||||||
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
|
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
|
||||||
// 上课时段:普通通知(含普通叫人)彻底隐藏;紧急消息回缩为**胶囊**(保持可见,允许再次展开)
|
// 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开)
|
||||||
if (this.state.inClass && !(this.state.active && this.isImmediate(this.state.active))) {
|
if (this.state.inClass && !this.isOpenable(this.state.active)) {
|
||||||
this.hideImmediately();
|
this.hideImmediately();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -526,13 +578,15 @@ class IslandController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private scheduleCollapse(delayMs: number): void {
|
private scheduleCollapse(delayMs: number): void {
|
||||||
|
// 设置页"预览效果":示例岛由 previewTimer 自行收尾(不参与"常驻胶囊"逻辑,也不因失焦收起)
|
||||||
|
if (this.previewId && this.state.active?.id === this.previewId) return;
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
this.collapseTimer = setTimeout(() => {
|
this.collapseTimer = setTimeout(() => {
|
||||||
this.collapseTimer = null;
|
this.collapseTimer = null;
|
||||||
// 上课时段 + 紧急消息(含紧急叫人):不自动隐藏(学生必须一直能看到、随时能再点开),
|
// 上课时段 + 紧急消息(含紧急叫人):不自动隐藏(学生必须一直能看到、随时能再点开),
|
||||||
// 只把展开卡回缩为胶囊;下课后再恢复为展开提示。
|
// 只把展开卡回缩为胶囊;下课后再恢复为展开提示。
|
||||||
const immediateActive = this.state.active;
|
const immediateActive = this.state.active;
|
||||||
if (this.state.inClass && immediateActive && this.isImmediate(immediateActive)) {
|
if (this.state.inClass && this.isOpenable(immediateActive)) {
|
||||||
if (this.state.mode === 'expanded') this.setState({ mode: 'pill' });
|
if (this.state.mode === 'expanded') this.setState({ mode: 'pill' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -560,6 +614,20 @@ class IslandController {
|
|||||||
clearTimeout(this.collapseTimer);
|
clearTimeout(this.collapseTimer);
|
||||||
this.collapseTimer = null;
|
this.collapseTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.previewTimer) {
|
||||||
|
clearTimeout(this.previewTimer);
|
||||||
|
this.previewTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结束设置页"预览效果":清定时器,并在需要时把示例通知移出灵动岛 */
|
||||||
|
private endPreview(id: string): void {
|
||||||
|
if (this.previewId === id) this.previewId = null;
|
||||||
|
if (this.previewTimer) {
|
||||||
|
clearTimeout(this.previewTimer);
|
||||||
|
this.previewTimer = null;
|
||||||
|
}
|
||||||
|
if (this.state.active?.id === id) this.dismissActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -620,8 +688,8 @@ class IslandController {
|
|||||||
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知 / 叫人"允许出现在屏幕上)
|
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知 / 叫人"允许出现在屏幕上)
|
||||||
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
|
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
|
||||||
// (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏)
|
// (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏)
|
||||||
const immediateShowing = this.state.active !== null && this.isImmediate(this.state.active);
|
const openableShowing = this.state.active !== null && this.isOpenable(this.state.active);
|
||||||
if (this.state.inClass && !immediateShowing) {
|
if (this.state.inClass && !openableShowing) {
|
||||||
this.hideImmediately();
|
this.hideImmediately();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -838,9 +906,13 @@ class IslandController {
|
|||||||
/** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */
|
/** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */
|
||||||
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void {
|
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void {
|
||||||
this.hitRect = rect && rect.width > 0 && rect.height > 0 ? { ...rect } : null;
|
this.hitRect = rect && rect.width > 0 && rect.height > 0 ? { ...rect } : null;
|
||||||
// 立即校正一次,避免最长 120ms 的"点了没反应"窗口
|
// 立即校正一次(形变期间渲染进程已限流到 ~10 次/秒,这里再兜一层 60ms 防止抖动)
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastHitSyncAt >= 60) {
|
||||||
|
this.lastHitSyncAt = now;
|
||||||
this.syncHitFromCursor();
|
this.syncHitFromCursor();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 当前岛体矩形(供冒烟验证) */
|
/** 当前岛体矩形(供冒烟验证) */
|
||||||
getHitRect(): { x: number; y: number; width: number; height: number } | null {
|
getHitRect(): { x: number; y: number; width: number; height: number } | null {
|
||||||
@@ -999,6 +1071,9 @@ const ISLAND_MARGIN = 8;
|
|||||||
*/
|
*/
|
||||||
const BLUR_GRACE_MS = 500;
|
const BLUR_GRACE_MS = 500;
|
||||||
|
|
||||||
|
/** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */
|
||||||
|
const PREVIEW_DURATION_MS = 30_000;
|
||||||
|
|
||||||
interface Bounds {
|
interface Bounds {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|||||||
@@ -848,8 +848,15 @@ async function runIslandChecks(
|
|||||||
|
|
||||||
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
|
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求
|
||||||
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
|
//(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间")
|
||||||
|
// 先排空队列:单条消息时胶囊标题应是类型名("新作业"),多条时才汇总成
|
||||||
|
// 「新消息:叫人/作业/通知(共 N 条)」——两种情况都要确定性覆盖。
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
const current = island.getState();
|
||||||
|
if (!current.active && current.queued.length === 0) break;
|
||||||
island.handleAction({ action: 'dismiss' });
|
island.handleAction({ action: 'dismiss' });
|
||||||
await sleep(400);
|
await sleep(120);
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
island.pushNotification(
|
island.pushNotification(
|
||||||
{
|
{
|
||||||
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
|
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
|
||||||
@@ -885,6 +892,48 @@ async function runIslandChecks(
|
|||||||
);
|
);
|
||||||
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
|
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
|
||||||
|
|
||||||
|
// 4.6b) 收起后的胶囊要说明"这批消息里都有什么":
|
||||||
|
// 单条 → 类型名(上面的"新作业");多条 → 「新消息:叫人/作业/通知(共 3 条)」+「点击查看」
|
||||||
|
island.pushNotification(
|
||||||
|
{
|
||||||
|
...makeNotification('smoke-summary-call', 'NORMAL', '请 王小明 同学找 张老师'),
|
||||||
|
kind: 'call',
|
||||||
|
subtitle: '请尽快前往,收到后点「收到」',
|
||||||
|
},
|
||||||
|
{ inClass: false },
|
||||||
|
);
|
||||||
|
await sleep(400);
|
||||||
|
island.pushNotification(makeNotification('smoke-summary-notice', 'NORMAL', '类型汇总校验'), {
|
||||||
|
inClass: false,
|
||||||
|
});
|
||||||
|
await sleep(600);
|
||||||
|
const summaryDom = await islandWindow?.webContents.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
const card = document.querySelector('.island-card.pill');
|
||||||
|
return {
|
||||||
|
title: card?.querySelector('.pill-title')?.textContent?.trim() ?? '',
|
||||||
|
sub: card?.querySelector('.pill-sub')?.textContent?.trim() ?? '',
|
||||||
|
types: Array.from(card?.querySelectorAll('.pill-type') ?? []).map((node) => node.textContent.trim()),
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
const summaryState = island.getState();
|
||||||
|
record(
|
||||||
|
'收起胶囊显示通知类型汇总(新消息:叫人/作业/通知(共 N 条))',
|
||||||
|
(summaryDom?.title ?? '').includes('新消息:') &&
|
||||||
|
(summaryDom?.title ?? '').includes('叫人') &&
|
||||||
|
(summaryDom?.title ?? '').includes('作业') &&
|
||||||
|
(summaryDom?.title ?? '').includes('通知') &&
|
||||||
|
(summaryDom?.title ?? '').includes('共 3 条') &&
|
||||||
|
(summaryDom?.sub ?? '').includes('点击查看') &&
|
||||||
|
(summaryDom?.types ?? []).length === 3 &&
|
||||||
|
summaryState.mode === 'pill',
|
||||||
|
`标题="${summaryDom?.title ?? '-'}" 副标题="${summaryDom?.sub ?? '-'}" ` +
|
||||||
|
`类型标签=[${(summaryDom?.types ?? []).join(',')}] mode=${summaryState.mode}`,
|
||||||
|
);
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(300);
|
||||||
|
|
||||||
// 4.7) "紧急叫人":URGENT + kind=call → 上课时段也立即展开
|
// 4.7) "紧急叫人":URGENT + kind=call → 上课时段也立即展开
|
||||||
island.handleAction({ action: 'dismiss' });
|
island.handleAction({ action: 'dismiss' });
|
||||||
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||||
@@ -928,8 +977,38 @@ async function runIslandChecks(
|
|||||||
);
|
);
|
||||||
await captureIsland('island-8-call', ISLAND_CALL_SIZE);
|
await captureIsland('island-8-call', ISLAND_CALL_SIZE);
|
||||||
|
|
||||||
// 4.8) "普通叫人":kind=call 但 priority=HIGH(服务端 urgent 缺省)→ 不打断课堂,
|
// 4.7b) 紧急叫人"收起 → 再次点开"(用户复现路径:点击缩回后也无法点开)
|
||||||
// 只进队列;与"紧急叫人"合并前的旧行为相反,这里守住回归。
|
await islandWindow?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
document.querySelector('.island-card')?.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
await sleep(600);
|
||||||
|
const urgentCallCollapsed = island.getState();
|
||||||
|
await islandWindow?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
document.querySelector('.island-card')?.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
await sleep(700);
|
||||||
|
const urgentCallReopened = island.getState();
|
||||||
|
record(
|
||||||
|
'紧急叫人收起后可再次点开(用户复现路径)',
|
||||||
|
urgentCallCollapsed.mode === 'pill' &&
|
||||||
|
urgentCallReopened.mode === 'expanded' &&
|
||||||
|
urgentCallReopened.active?.id === 'smoke-call',
|
||||||
|
`收起后=${urgentCallCollapsed.mode} 再次点开=${urgentCallReopened.mode} ` +
|
||||||
|
`active=${urgentCallReopened.active?.id ?? '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4.8) "普通叫人":kind=call 但 priority=HIGH(服务端 urgent 缺省)→ **不自动展开**(不打断课堂),
|
||||||
|
// 但上课时段仍以胶囊保持可见,并且必须能点开(用户反馈:"非紧急叫人无法点开灵动岛")。
|
||||||
island.handleAction({ action: 'dismiss' });
|
island.handleAction({ action: 'dismiss' });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
island.pushNotification(
|
island.pushNotification(
|
||||||
@@ -942,15 +1021,41 @@ async function runIslandChecks(
|
|||||||
);
|
);
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
const normalCallState = island.getState();
|
const normalCallState = island.getState();
|
||||||
|
const normalCallVisible = islandWindow?.isVisible() ?? false;
|
||||||
record(
|
record(
|
||||||
'普通叫人只进队列(上课时段不打断、下课再弹)',
|
'普通叫人上课时段只显示胶囊(不自动展开、不打断课堂)',
|
||||||
normalCallState.mode === 'hidden' &&
|
normalCallState.mode === 'pill' &&
|
||||||
normalCallState.active?.id !== 'smoke-call-normal' &&
|
normalCallState.active?.id === 'smoke-call-normal' &&
|
||||||
normalCallState.queued.some((item) => item.id === 'smoke-call-normal'),
|
normalCallState.reason === 'call' &&
|
||||||
`mode=${normalCallState.mode} active=${normalCallState.active?.id ?? '-'} ` +
|
normalCallVisible,
|
||||||
`queued=[${normalCallState.queued.map((item) => item.id).join(',')}]`,
|
`mode=${normalCallState.mode} reason=${normalCallState.reason} 可见=${normalCallVisible} ` +
|
||||||
|
`active=${normalCallState.active?.id ?? '-'}`,
|
||||||
|
);
|
||||||
|
// 上课时段点开普通叫人:必须能展开(这正是用户复现的那条路径)
|
||||||
|
await islandWindow?.webContents
|
||||||
|
.executeJavaScript(
|
||||||
|
`(() => {
|
||||||
|
document.querySelector('.island-card')?.click();
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
)
|
||||||
|
.catch(() => undefined);
|
||||||
|
await sleep(700);
|
||||||
|
const normalCallOpened = island.getState();
|
||||||
|
record(
|
||||||
|
'普通叫人上课时段可以点开(用户复现路径)',
|
||||||
|
normalCallOpened.mode === 'expanded' && normalCallOpened.active?.id === 'smoke-call-normal',
|
||||||
|
`点击后 mode=${normalCallOpened.mode} active=${normalCallOpened.active?.id ?? '-'}`,
|
||||||
|
);
|
||||||
|
// 再收起 → 仍是胶囊(保持可见、可再次点开),下课后自动展开
|
||||||
|
await island.handleAction({ action: 'collapse' });
|
||||||
|
await sleep(500);
|
||||||
|
const normalCallCollapsed = island.getState();
|
||||||
|
record(
|
||||||
|
'普通叫人收起后仍保留胶囊(可再次点开)',
|
||||||
|
normalCallCollapsed.mode === 'pill' && normalCallCollapsed.active?.id === 'smoke-call-normal',
|
||||||
|
`mode=${normalCallCollapsed.mode} active=${normalCallCollapsed.active?.id ?? '-'}`,
|
||||||
);
|
);
|
||||||
// 下课:队列中的普通叫人自动弹出(与普通通知同一路径)
|
|
||||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||||
await sleep(600);
|
await sleep(600);
|
||||||
const afterClassCall = island.getState();
|
const afterClassCall = island.getState();
|
||||||
@@ -1162,6 +1267,46 @@ async function runIslandChecks(
|
|||||||
`mode=${island.getState().mode} 命中框=${rectText(hitRectHidden)}`,
|
`mode=${island.getState().mode} 命中框=${rectText(hitRectHidden)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4.11) 设置页"预览效果"(用户反馈:预览无法正常展开示例岛)
|
||||||
|
// 预览必须直接展开、失焦不收起(用户调滑块时主窗口一直是焦点),并在到点后自行消失。
|
||||||
|
await drainForCollapseCheck();
|
||||||
|
await sleep(300);
|
||||||
|
island.pushNotification(
|
||||||
|
{
|
||||||
|
...makeNotification('appearance-test-smoke', 'NORMAL', '灵动岛外观预览'),
|
||||||
|
content: '拖动滑块即可实时预览。',
|
||||||
|
teacherName: '本地预览',
|
||||||
|
},
|
||||||
|
{ inClass: false, preview: true },
|
||||||
|
);
|
||||||
|
await sleep(700);
|
||||||
|
const previewState = island.getState();
|
||||||
|
const previewVisible = islandWindow?.isVisible() ?? false;
|
||||||
|
const previewGeometry = await readIslandGeometry();
|
||||||
|
// 失焦(等价于用户回到设置页拖滑块)不应把示例岛收起来
|
||||||
|
islandWindow?.emit('blur');
|
||||||
|
await sleep(700);
|
||||||
|
const previewAfterBlur = island.getState();
|
||||||
|
record(
|
||||||
|
'设置页预览:示例岛直接展开且失焦不收起',
|
||||||
|
previewState.mode === 'expanded' &&
|
||||||
|
previewState.active?.id === 'appearance-test-smoke' &&
|
||||||
|
previewVisible &&
|
||||||
|
(previewGeometry?.island.width ?? 0) > ISLAND_SIZES.pill.width &&
|
||||||
|
previewAfterBlur.mode === 'expanded',
|
||||||
|
`预览态=${previewState.mode} 可见=${previewVisible} 岛宽=${previewGeometry?.island.width?.toFixed(0) ?? '-'}` +
|
||||||
|
` 失焦后=${previewAfterBlur.mode}`,
|
||||||
|
);
|
||||||
|
// 预览通知不该被"常驻胶囊"逻辑留下:点"知道了"后必须彻底消失
|
||||||
|
island.handleAction({ action: 'dismiss' });
|
||||||
|
await sleep(600);
|
||||||
|
const previewDismissed = island.getState();
|
||||||
|
record(
|
||||||
|
'设置页预览:关掉后不残留(不会变成常驻胶囊)',
|
||||||
|
previewDismissed.mode === 'hidden' && previewDismissed.active === null,
|
||||||
|
`mode=${previewDismissed.mode} active=${previewDismissed.active?.id ?? '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||||
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
||||||
const shotDetails = islandShots.map((shot) => {
|
const shotDetails = islandShots.map((shot) => {
|
||||||
@@ -1294,6 +1439,14 @@ async function runIslandChecks(
|
|||||||
// - 卡片布局尺寸用 offsetWidth/offsetTop(不含 CSS transform),只允许在"详情/胶囊"两种固定值间切换
|
// - 卡片布局尺寸用 offsetWidth/offsetTop(不含 CSS transform),只允许在"详情/胶囊"两种固定值间切换
|
||||||
island.setAppearance(appearanceBefore);
|
island.setAppearance(appearanceBefore);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
// 前面几节的收尾可能已经清空队列:这里保证有"正在展示的通知",否则 expand 无可展开,
|
||||||
|
// 采样只会拿到隐藏态的空数据(这条断言就失去意义)。
|
||||||
|
if (!island.getState().active) {
|
||||||
|
island.pushNotification(makeNotification('smoke-jitter', 'NORMAL', '开合抖动校验'), {
|
||||||
|
inClass: false,
|
||||||
|
});
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
island.handleAction({ action: 'expand' });
|
island.handleAction({ action: 'expand' });
|
||||||
await sleep(700);
|
await sleep(700);
|
||||||
const jitterSamples: { win: number; cardWidth: number; cardTop: number }[] = [];
|
const jitterSamples: { win: number; cardWidth: number; cardTop: number }[] = [];
|
||||||
@@ -1606,6 +1759,8 @@ async function runIslandChecks(
|
|||||||
});
|
});
|
||||||
await waitForIslandDom('.island-card.pill .pill-title');
|
await waitForIslandDom('.island-card.pill .pill-title');
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
|
// 命中兜底轮询读的是"岛体矩形":等它上报后再注入光标(渲染进程上报有限流)
|
||||||
|
for (let index = 0; index < 20 && !island.getHitRect(); index += 1) await sleep(80);
|
||||||
await islandWindow?.webContents
|
await islandWindow?.webContents
|
||||||
.executeJavaScript(
|
.executeJavaScript(
|
||||||
`(() => {
|
`(() => {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ async function resetIslandAppearance(): Promise<void> {
|
|||||||
await saveIslandAppearance();
|
await saveIslandAppearance();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本地推一条测试通知,立刻确认外观效果 */
|
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
|
||||||
function testIsland(): void {
|
function testIsland(): void {
|
||||||
window.desktop?.islandPush?.({
|
window.desktop?.islandPush?.({
|
||||||
notification: {
|
notification: {
|
||||||
@@ -172,7 +172,7 @@ function testIsland(): void {
|
|||||||
courseName: null,
|
courseName: null,
|
||||||
teacherName: '本地预览',
|
teacherName: '本地预览',
|
||||||
},
|
},
|
||||||
context: { inClass: false, currentPeriodEnd: null },
|
context: { inClass: false, currentPeriodEnd: null, preview: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface IslandPushContext {
|
|||||||
/** 当前这节课的结束时间(HH:mm) */
|
/** 当前这节课的结束时间(HH:mm) */
|
||||||
currentPeriodEnd?: string | null;
|
currentPeriodEnd?: string | null;
|
||||||
week?: number;
|
week?: number;
|
||||||
|
/** 设置页"预览效果":直接展开示例岛、失焦不收起、30 秒后自行消失 */
|
||||||
|
preview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IslandClassStatePayload {
|
export interface IslandClassStatePayload {
|
||||||
|
|||||||
Reference in new issue
Block a user