fix(island,server): 连续圆角四角修正 + 叫人分紧急/普通两级

灵动岛圆角(用户反馈"左上角和右下角缺一块 / 圆角边缘割裂"):
- squircle.ts 改为"每个角独立圆心 + 角度参数"的超椭圆采样(原本四角共用同一个偏移向量,
  左上/右下被切掉一块);四角几何与 WinIsland 的连续超椭圆一致,r 与 h/2、w/2 自动收敛。
- verify:desktop 新增像素级断言「连续圆角四角一致」:从四角沿对角线找岛体边界,
  四角步进必须两两一致(展开 15/15/15/15、紧急 14/14/14/14、胶囊 5/5/5/5),
  并保留路径绕行 |winding|≈2π 断言(抓"角上往回折")。该断言即本次回归的守卫。
- 截图留档同时写入裁剪图与整窗图,便于人工复核角形。

叫人分紧急 / 普通两级(用户要求"叫人也分为紧急和普通"):
- shared:CreateCallRequest 新增 urgent;紧急叫人 → URGENT(无视上课时段立即展开),
  普通叫人(默认)→ HIGH(按普通通知排队)。
- desktop:isImmediate 只认 priority=URGENT,不再把 kind=call 一律视为立即消息;
  普通叫人回到与普通通知相同的排队 / 隐藏 / 下课弹出路径。
- web:通知发布 → 叫人、学生管理 → 叫人 两个弹窗新增"级别"单选(默认普通)+ 联动提示文案。

验证:verify:e2e 135/135(新增普通 / 紧急叫人级别断言)、verify:web 20/20(新增级别单选断言)、
verify:desktop 59/59 + 离线复跑 53/53、typecheck / lint / format 全绿。
This commit is contained in:
LoveChengke committed 2026-09-12 19:54:00 +08:00
1 parent 1b4dd14e4a
commit 0690546307
13 files changed
+553 -205

No files matched your search

+29 -10
View File
@@ -265,17 +265,18 @@ class IslandController {
/**
* 是否需要"立即展开、无视上课时段"。
* 紧急通知与"叫人"(老师点名)都属于:学生必须马上看到。
* 只认 URGENT:紧急通知、以及"紧急叫人"(服务端 urgent=true 落库为 URGENT)。
* 普通叫人(HIGH)与普通通知同等待遇 —— 上课时段只进队列,不打断课堂。
*/
private isImmediate(notification: IslandNotification): boolean {
return notification.priority === 'URGENT' || notification.kind === 'call';
return notification.priority === 'URGENT';
}
/**
* 收到一条消息。
* - 紧急 / 叫人:立刻展开(无视上课时段)
* - 紧急(含紧急叫人):立刻展开(无视上课时段)
* - 上课中:进队列并保持隐藏(下课后由 setClassState 触发弹出)
* - 其它:显示"新消息/新作业"胶囊,等待点击展开
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
*/
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
const inClass = context.inClass ?? this.state.inClass;
@@ -293,7 +294,7 @@ class IslandController {
this.activate(notification, reason, 'expanded');
logger.info(
notification.kind === 'call'
? `灵动岛叫人消息:${notification.title}`
? `灵动岛紧急叫人消息:${notification.title}`
: `灵动岛紧急插播:${notification.title}`,
);
return;
@@ -338,6 +339,14 @@ class IslandController {
}
if (wasInClass && !payload.inClass) {
const active = this.state.active;
if (active && this.isImmediate(active) && this.state.mode === 'pill') {
// 上课期间被收起的紧急消息(含紧急叫人):下课后自动展开,避免学生错过
logger.info('灵动岛:下课,重新展开被收起的紧急消息');
this.setState({ mode: 'expanded' });
this.scheduleCollapse(active.kind === 'call' ? TIMEOUTS.call : TIMEOUTS.urgent);
return;
}
logger.info('灵动岛:下课,弹出上课期间暂存的通知');
this.flushQueueAfterClass();
return;
@@ -362,9 +371,10 @@ class IslandController {
handleAction(payload: IslandActionPayload): void {
switch (payload.action) {
case 'expand': {
// 上课时段一律不显示灵动岛(只有紧急通知 / 叫人才会自动展开)
if (this.state.inClass) break;
const active = this.state.active;
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
if (this.state.inClass && !(active && this.isImmediate(active))) break;
if (active) {
this.setState({ mode: 'expanded' });
this.scheduleCollapse(
@@ -379,7 +389,8 @@ class IslandController {
}
case 'collapse':
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
if (this.state.inClass) {
// 上课时段:普通通知(含普通叫人)彻底隐藏;紧急消息回缩为**胶囊**(保持可见,允许再次展开)
if (this.state.inClass && !(this.state.active && this.isImmediate(this.state.active))) {
this.hideImmediately();
break;
}
@@ -497,6 +508,13 @@ class IslandController {
this.clearTimers();
this.collapseTimer = setTimeout(() => {
this.collapseTimer = null;
// 上课时段 + 紧急消息(含紧急叫人):不自动隐藏(学生必须一直能看到、随时能再点开),
// 只把展开卡回缩为胶囊;下课后再恢复为展开提示。
const immediateActive = this.state.active;
if (this.state.inClass && immediateActive && this.isImmediate(immediateActive)) {
if (this.state.mode === 'expanded') this.setState({ mode: 'pill' });
return;
}
if (this.state.mode === 'expanded') {
const hasMore = this.state.queued.length > 1;
if (hasMore) {
@@ -574,8 +592,9 @@ class IslandController {
if (!this.win || this.win.isDestroyed()) return;
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知/叫人"允许出现在屏幕上)
const immediateShowing =
this.state.mode === 'expanded' && this.state.active !== null && this.isImmediate(this.state.active);
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
// (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏)
const immediateShowing = this.state.active !== null && this.isImmediate(this.state.active);
if (this.state.inClass && !immediateShowing) {
this.hideImmediately();
return;