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

+41 -39
View File
@@ -1,16 +1,20 @@
/**
* 连续圆角(squircle / Apple 连续曲率圆角)路径生成。
*
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表
* 构造 `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡的半径取
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表构造
* `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡半径取
* `expanded_island_radius = min(48 * scale, w/2, h/2)`。
*
* 这里用等价的超椭圆角公式生成 SVG path:
* 角点区域按 x = r * (1 - cos^p(t)), y = r * (1 - sin^p(t)) 采样,
* 其中 p = 2/n、n 取 4.2 时最接近 Apple 的"连续曲率"观感(普通圆角相当于 n = 2)。
* 直边部分保持完全笔直,因此与 WinIsland 的"直边 + 连续圆角"一致。
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、没有方向歧义。
*
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),所以圆角半径恒定、不被拉伸。
* 早期版本用「相对角起点的偏移向量」拼角,而四个角需要的偏移向量**并不相同**
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`)——
* 于是左上与右下被画成了向内凹的形状,视觉上就是这两个角**缺一块**。
*
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),因此圆角半径恒定、不被拉伸。
*/
/** 超椭圆指数:2 = 标准圆弧圆角,越大越"方"但曲率越连续(Apple 观感约 4~5) */
@@ -19,27 +23,20 @@ const SUPERELLIPSE_EXPONENT = 4.2;
/** 每个角的采样段数(越多越平滑;4 个角共 4x 段) */
const CORNER_SEGMENTS = 14;
function cornerPoints(radius: number): { x: number; y: number }[] {
const p = 2 / SUPERELLIPSE_EXPONENT;
const points: { x: number; y: number }[] = [];
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
const t = (index / CORNER_SEGMENTS) * (Math.PI / 2);
const cos = Math.max(0, Math.cos(t));
const sin = Math.max(0, Math.sin(t));
// t=0 → 角外侧(贴直边),t=π/2 → 角内侧
points.push({
x: radius * (1 - Math.pow(cos, p)),
y: radius * (1 - Math.pow(sin, p)),
});
}
return points;
}
const round = (value: number): number => Math.round(value * 100) / 100;
/**
* 生成连续圆角矩形路径。
*
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、不会出现方向歧义。
*
* 早期版本用「相对起点的偏移向量」拼角,而四个角需要的偏移向量并不相同
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`),
* 于是左上与右下被画成了"向内凹"的形状——视觉上就是这两个角**缺一块**。
*
* @param width 卡片宽度(px)
* @param height 卡片高度(px)
* @param radius 四角基准半径(px,会被裁剪到 min(w/2, h/2))
@@ -51,23 +48,28 @@ export function squirclePath(width: number, height: number, radius: number): str
if (r <= 0.5) return `M0,0 H${round(w)} V${round(h)} H0 Z`;
const corner = cornerPoints(r);
const parts: string[] = [];
const p = 2 / SUPERELLIPSE_EXPONENT;
const centers = [
{ cx: w - r, cy: r }, // 右上
{ cx: w - r, cy: h - r }, // 右下
{ cx: r, cy: h - r }, // 左下
{ cx: r, cy: r }, // 左上
];
const startAngles = [-Math.PI / 2, 0, Math.PI / 2, Math.PI];
// 从左上角的"上方直边起点"开始,顺时针绘制
parts.push(`M${round(r)},0`);
// 上边 → 右上角
parts.push(`H${round(w - r)}`);
for (const point of corner) parts.push(`L${round(w - r + point.x)},${round(point.y)}`);
// 右边 → 右下角
parts.push(`V${round(h - r)}`);
for (const point of corner) parts.push(`L${round(w - point.y)},${round(h - r + point.x)}`);
// 下边 → 左下角
parts.push(`H${round(r)}`);
for (const point of corner) parts.push(`L${round(r - point.x)},${round(h - point.y)}`);
// 左边 → 左上角
parts.push(`V${round(r)}`);
for (const point of corner) parts.push(`L${round(point.y)},${round(r - point.x)}`);
const parts: string[] = [];
for (let corner = 0; corner < 4; corner += 1) {
const center = centers[corner] as { cx: number; cy: number };
const startAngle = startAngles[corner] as number;
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
const angle = startAngle + (index / CORNER_SEGMENTS) * (Math.PI / 2);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const px = center.cx + r * Math.sign(cos) * Math.pow(Math.abs(cos), p);
const py = center.cy + r * Math.sign(sin) * Math.pow(Math.abs(sin), p);
parts.push(`${index === 0 && corner === 0 ? 'M' : 'L'}${round(px)},${round(py)}`);
}
}
parts.push('Z');
return parts.join(' ');
+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;
+220 -11
View File
@@ -36,6 +36,10 @@ interface IslandRect {
radius?: number;
pathWidth?: number;
pathHeight?: number;
/** 形状路径绕行总角度(凸、不自交时 |winding| ≈ 2π) */
winding?: number;
/** 绕行单调性:角的采样参数写反时会在角上“往回折”,这里会变 false */
windingOk?: boolean;
}
interface IslandShotStats {
@@ -55,6 +59,8 @@ interface IslandShotStats {
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
outsideReddish?: number;
filePath: string;
/** 四角圆角几何检查:四个角的边界步进必须一致(抓“角缺一块 / 角画反”) */
corners?: { steps: number[]; expectedInset: number; spread: number; limit: number };
}
const islandShots: IslandShotStats[] = [];
@@ -160,6 +166,18 @@ async function captureIsland(
}
const size = cropped.getSize();
// 四角圆角像素检查:半径取自卡片 CSS 变量 --card-r(与 SVG path 同源)
const scaleRatio = image.getSize().width / Math.max(1, geometry?.window.width ?? image.getSize().width);
const cardRadius = await win.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return 0;
return Number.parseFloat(getComputedStyle(card).getPropertyValue('--card-r')) || 0;
})()`,
)
.catch(() => 0);
const corners = cardRadius > 0 ? analyzeCorners(cropped, cardRadius, scaleRatio) : undefined;
const stats: IslandShotStats = {
name,
expected,
@@ -167,6 +185,7 @@ async function captureIsland(
height: size.height,
filePath,
...analyzeBitmap(cropped),
corners,
outsideReddish,
};
islandShots.push(stats);
@@ -250,6 +269,62 @@ function cropToIsland(
return image.crop({ x, y, width, height });
}
/**
* 圆角像素级检查(专抓“圆角缺一块 / 角画反”这类几何错误)。
*
* 做法:从裁剪图的四个角沿对角线向内逐像素走,找到第一个“连续 3 像素属于岛体”的位置,
* 记为该角的边界步进 `steps`。连续圆角(superellipse n=4.2)理论边界在 0.153r 处,
* 四个角必须互相一致——这正是之前的回归点:旧实现四个角共用同一个偏移向量,
* 左上/右下被切掉一块,两两之间步进会明显不同。该指标只看角尖端 2~7px,与卡片内文字无关。
*/
function analyzeCorners(
image: NativeImage,
radius: number,
ratio: number,
): { steps: number[]; expectedInset: number; spread: number; limit: number } {
const bitmap = image.toBitmap();
const size = image.getSize();
const r = Math.max(3, radius * ratio);
// “岛体存在”判定:与截图时注入的窗口背景色 #1f2733 差异明显即算岛体(纯黑填充、红色描边、
// 白色文字都算),这样紧急态顶部的红色渐层不会把四角判定带偏。
const bg = { r: 0x1f, g: 0x27, b: 0x33 };
const isFill = (x: number, y: number): boolean => {
if (x < 0 || y < 0 || x >= size.width || y >= size.height) return false;
const index = (y * size.width + x) * 4;
const blue = bitmap[index] ?? 255;
const green = bitmap[index + 1] ?? 255;
const red = bitmap[index + 2] ?? 255;
return Math.abs(red - bg.r) > 16 || Math.abs(green - bg.g) > 16 || Math.abs(blue - bg.b) > 16;
};
const signs: [number, number][] = [
[1, 1],
[-1, 1],
[1, -1],
[-1, -1],
];
const maxSteps = Math.ceil(r) + 2;
const steps: number[] = [];
for (const [sx, sy] of signs) {
let found = maxSteps;
for (let k = 0; k <= maxSteps; k += 1) {
const x = sx > 0 ? k : size.width - 1 - k;
const y = sy > 0 ? k : size.height - 1 - k;
if (isFill(x, y) && isFill(x + sx, y + sy) && isFill(x + 2 * sx, y + 2 * sy)) {
found = k;
break;
}
}
steps.push(found);
}
const spread = Math.max(...steps) - Math.min(...steps);
return {
steps,
expectedInset: Number((0.153 * r).toFixed(2)),
spread,
limit: Math.round(0.3 * r) + 1,
};
}
/** 偏红像素的包围盒(诊断形状错位:与几何读数对比即可定位偏差来源) */
function reddishBBox(image: Electron.NativeImage): { x: number; y: number; width: number; height: number } {
const bitmap = image.toBitmap();
@@ -451,6 +526,32 @@ async function runIslandChecks(
const raw = getComputedStyle(card).getPropertyValue('--card-r').trim();
const path = card.querySelector('.shape path');
const box = path && typeof path.getBBox === 'function' ? path.getBBox() : null;
// 路径绕行方向检查:沿路径等距采样,相邻点的方位角必须单调递增(凸、不自交)。
// 角的采样参数若写反,路径会在角上“往回折”,这里会立刻暴露。
let winding = 0;
let windingOk = true;
if (path && typeof path.getTotalLength === 'function') {
const total = path.getTotalLength();
const points = [];
for (let index = 0; index < 96; index += 1) {
const point = path.getPointAtLength((index / 96) * total);
points.push([point.x, point.y]);
}
const cx = points.reduce((sum, p) => sum + p[0], 0) / points.length;
const cy = points.reduce((sum, p) => sum + p[1], 0) / points.length;
let previousAngle = null;
for (const [x, y] of points) {
const angle = Math.atan2(y - cy, x - cx);
if (previousAngle !== null) {
let delta = angle - previousAngle;
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
if (delta < -0.05) windingOk = false;
winding += delta;
}
previousAngle = angle;
}
}
return {
left: r.left,
top: r.top,
@@ -459,6 +560,8 @@ async function runIslandChecks(
radius: parseFloat(raw) || 0,
pathWidth: box ? box.width : 0,
pathHeight: box ? box.height : 0,
winding,
windingOk,
};
})()`,
)
@@ -474,6 +577,8 @@ async function runIslandChecks(
radius: rect.radius,
pathWidth: rect.pathWidth,
pathHeight: rect.pathHeight,
winding: rect.winding,
windingOk: rect.windingOk,
},
};
};
@@ -527,13 +632,44 @@ async function runIslandChecks(
);
await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE);
// 3.1) 上课期间请求"收起"也不应显示胶囊(上课严格不显示灵动岛)
// 3.1) 上课期间“收起”紧急通知:只回缩为胶囊且保持可见,并且还能再次展开(回归)
island.handleAction({ action: 'collapse' });
await sleep(400);
await sleep(450);
const collapsedUrgent = island.getState();
const collapsedGeometry = await readIslandGeometry();
record(
'上课期间收起不会回缩成胶囊(严格不显示)',
island.getState().mode === 'hidden' && !(islandWindow?.isVisible() ?? true),
`mode=${island.getState().mode} visible=${islandWindow?.isVisible() ?? true}`,
'上课期间紧急通知收起后回缩为胶囊且保持可见',
collapsedUrgent.mode === 'pill' &&
(islandWindow?.isVisible() ?? false) &&
Math.abs((collapsedGeometry?.island.width ?? 0) - ISLAND_SIZES.pill.width) <= 2,
`mode=${collapsedUrgent.mode} visible=${islandWindow?.isVisible() ?? '-'} ` +
`岛=${collapsedGeometry?.island.width?.toFixed(0) ?? '-'}x${collapsedGeometry?.island.height?.toFixed(0) ?? '-'}`,
);
island.handleAction({ action: 'expand' });
await sleep(600);
const reExpanded = island.getState();
const reExpandedGeometry = await readIslandGeometry();
record(
'上课期间紧急通知回收后可再次展开(回归)',
reExpanded.mode === 'expanded' &&
reExpanded.active?.id === 'smoke-urgent-in-class' &&
Math.abs((reExpandedGeometry?.island.width ?? 0) - ISLAND_URGENT_SIZE.width) <= 2,
`mode=${reExpanded.mode} active=${reExpanded.active?.id ?? '-'} ` +
`岛=${reExpandedGeometry?.island.width?.toFixed(0) ?? '-'}x${reExpandedGeometry?.island.height?.toFixed(0) ?? '-'}` +
`(期望 ${ISLAND_URGENT_SIZE.width})`,
);
island.pushNotification(makeNotification('smoke-normal-in-class-2', 'NORMAL', '上课中的普通通知'), {
inClass: true,
});
await sleep(350);
island.handleAction({ action: 'collapse' });
await sleep(300);
const afterNormalPush = island.getState();
record(
'上课期间普通通知只进队列(不顶掉正在展示的紧急消息)',
afterNormalPush.active?.id === 'smoke-urgent-in-class' &&
afterNormalPush.queued.some((item) => item.id === 'smoke-normal-in-class-2'),
`active=${afterNormalPush.active?.id ?? '-'} queued=[${afterNormalPush.queued.map((item) => item.id).join(',')}]`,
);
// 4) 非上课时段普通通知 → 直接显示胶囊(无入场动画);点击后展开
@@ -639,6 +775,8 @@ async function runIslandChecks(
radius: number;
pathWidth: number;
pathHeight: number;
winding?: number;
windingOk?: boolean;
}[] = [];
const shrinkSampler = setInterval(() => {
void readIslandGeometry()
@@ -650,6 +788,8 @@ async function runIslandChecks(
radius: Number(geometry.island.radius ?? 0),
pathWidth: Number(geometry.island.pathWidth ?? 0),
pathHeight: Number(geometry.island.pathHeight ?? 0),
winding: Number(geometry.island.winding ?? 0),
windingOk: geometry.island.windingOk === true,
});
})
.catch(() => undefined);
@@ -665,17 +805,24 @@ async function runIslandChecks(
(width, index) => index === 0 || width <= (islandWidths[index - 1] ?? width) + 1,
);
// 画出来的形状必须与卡片框一致:SVG 路径 bbox ≈ 卡片宽高(±2px)。
// 这能一次性抓住"viewBox/preserveAspectRatio 错位导致形状被缩放或残留角"这类问题。
// 这能一次性抓住“viewBox/preserveAspectRatio 错位导致形状被缩放或残留角”这类问题。
const pathFits = shrinkSamples.every(
(item) =>
Math.abs(item.pathWidth - Number(item.island.split('x')[0])) <= 2 &&
Math.abs(item.pathHeight - Number(item.island.split('x')[1])) <= 2,
);
// 圆角绕行方向:连续圆角路径必须凸且不自交(|绕行| ≈ 2π);角的采样写反会在形变中被抓到
const windingSamples = shrinkSamples.filter((item) => item.windingOk !== undefined);
const windingOk =
windingSamples.length > 0 &&
windingSamples.every((item) => item.windingOk === true) &&
windingSamples.every((item) => Math.abs(Math.abs(item.winding ?? 0) - Math.PI * 2) < 0.6);
record(
'开合过程中窗口全程恒定、岛在窗口内连续形变(照搬 WinIsland 架构)',
shrinkSamples.length >= 3 && windowSignatures.length === 1 && islandMonotonic && pathFits,
shrinkSamples.length >= 3 && windowSignatures.length === 1 && islandMonotonic && pathFits && windingOk,
`采样=${shrinkSamples.length} 窗口签名=[${windowSignatures.join(' ')}](要求只有 1 种) ` +
`岛尺寸序列=[${islandSizes.slice(0, 8).join('→')}${islandSizes.length > 8 ? '…' : ''}] 单调收缩=${islandMonotonic} 形状与卡片框一致=${pathFits}`,
`岛尺寸序列=[${islandSizes.slice(0, 8).join('→')}${islandSizes.length > 8 ? '…' : ''}] 单调收缩=${islandMonotonic} ` +
`形状与卡片框一致=${pathFits} 圆角绕行正确=${windingOk}(|绕行|=${(windingSamples[0]?.winding ?? 0).toFixed(2)})`,
);
// 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊
@@ -731,13 +878,13 @@ async function runIslandChecks(
);
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
// 4.7) "叫人":上课时段也立即展开,展示"请 XXX 同学找 XXX 老师"
// 4.7) "紧急叫人":URGENT + kind=call → 上课时段也立即展开
island.handleAction({ action: 'dismiss' });
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(400);
island.pushNotification(
{
...makeNotification('smoke-call', 'HIGH', '请 王小明 同学找 张老师'),
...makeNotification('smoke-call', 'URGENT', '请 王小明 同学找 张老师'),
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
teacherName: '张老师',
@@ -762,7 +909,7 @@ async function runIslandChecks(
}))()`,
);
record(
'叫人消息上岛(上课时段也立即展开,无需点击)',
'紧急叫人消息上岛(上课时段也立即展开,无需点击)',
callState.mode === 'expanded' &&
callState.active?.kind === 'call' &&
callState.reason === 'call' &&
@@ -773,7 +920,40 @@ async function runIslandChecks(
`徽标=${callDom?.badge} 标题="${callDom?.title}" 按钮=${callDom?.solid}`,
);
await captureIsland('island-8-call', ISLAND_CALL_SIZE);
// 4.8) "普通叫人":kind=call 但 priority=HIGH(服务端 urgent 缺省)→ 不打断课堂,
// 只进队列;与"紧急叫人"合并前的旧行为相反,这里守住回归。
island.handleAction({ action: 'dismiss' });
await sleep(300);
island.pushNotification(
{
...makeNotification('smoke-call-normal', 'HIGH', '请 王小明 同学找 张老师'),
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
},
{ inClass: true },
);
await sleep(500);
const normalCallState = island.getState();
record(
'普通叫人只进队列(上课时段不打断、下课再弹)',
normalCallState.mode === 'hidden' &&
normalCallState.active?.id !== 'smoke-call-normal' &&
normalCallState.queued.some((item) => item.id === 'smoke-call-normal'),
`mode=${normalCallState.mode} active=${normalCallState.active?.id ?? '-'} ` +
`queued=[${normalCallState.queued.map((item) => item.id).join(',')}]`,
);
// 下课:队列中的普通叫人自动弹出(与普通通知同一路径)
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(600);
const afterClassCall = island.getState();
record(
'下课后普通叫人自动弹出详情(叫人文案不丢)',
afterClassCall.mode === 'expanded' && afterClassCall.active?.id === 'smoke-call-normal',
`mode=${afterClassCall.mode} active=${afterClassCall.active?.id ?? '-'} kind=${afterClassCall.active?.kind}`,
);
island.handleAction({ action: 'dismiss' });
await sleep(400);
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(400);
@@ -826,6 +1006,28 @@ async function runIslandChecks(
`紧急态岛内偏红=${urgentReddish} 岛外留白偏红=${outsideReddish}(要求 0)`,
);
// 7b) 圆角几何:从四角沿对角线向内找边界,四个角的步进必须一致(旧实现四角共用同一个
// 偏移向量,左上/右下被切掉一块,两两步进差异明显)。只看角尖端 2~7px,与文字无关。
const cornerShots = shotDetails.filter((shot) => shot.corners);
const badCorners = cornerShots.filter((shot) => {
const corner = shot.corners;
if (!corner) return false;
return corner.spread > 2 || corner.steps.some((step) => step < 1 || step > corner.limit);
});
const cornerReport = cornerShots
.map((shot) => {
const corner = shot.corners;
if (!corner) return shot.name;
const flag = corner.spread > 2 ? ' ←离散超限' : '';
return `${shot.name} 步进=[${corner.steps.join(',')}] 理论=${corner.expectedInset} 离散=${corner.spread}${flag}`;
})
.join(' | ');
record(
'圆角四角一致(无“缺一块 / 角画反”:四角边界步进一致且落在连续圆角理论值内)',
cornerShots.length >= 4 && badCorners.length === 0,
cornerReport,
);
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
const appearanceBefore = island.getAppearance();
island.setAppearance({ height: 56, width: 300, radius: 26, fontSize: 14, opacity: 0.92, speed: 1.5 });
@@ -1192,6 +1394,13 @@ async function runIslandChecks(
);
// 10.7b) 固定大包围盒窗口的鼠标穿透(照搬 WinIsland set_cursor_hittest):
// 默认整块窗口穿透,指针进入岛体才接收鼠标 —— 否则会吞掉桌面点击。
// 指针进入岛体前先保证岛真的画出来了(隐藏态曾让这条断言偶发失败)
await drainIsland();
island.pushNotification(makeNotification('smoke-interactive', 'NORMAL', '鼠标穿透校验'), {
inClass: false,
});
await waitForIslandDom('.island-card.pill .pill-title');
await sleep(200);
await islandWindow?.webContents
.executeJavaScript(
`(() => {
@@ -110,7 +110,11 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void {
});
}
/** "叫人"上岛:无论是否上课都立即展开,展示"请 XXX 同学找 XXX 老师" */
/**
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
* 两种都带"叫人"类型(徽标/「收到」按钮/更大的展开卡)。
*/
export function pushCallToIsland(call: NotificationDto): void {
window.desktop?.islandPush({
notification: {
+25 -4
View File
@@ -567,6 +567,27 @@ async function main() {
callByTeacher.status === 201 && callTitle.includes('请') && callTitle.includes('找'),
`status=${callByTeacher.status} title="${callTitle}"`,
);
record(
'普通叫人(默认)不是紧急级别:priority=HIGH,学生端按普通通知排队',
callByTeacher.payload?.data?.priority === 'HIGH',
`priority=${callByTeacher.payload?.data?.priority ?? '-'}(期望 HIGH)`,
);
const urgentCall = await api('/calls', {
method: 'POST',
token: teacherToken,
body: {
classId,
studentId: studentUser.id,
quickPhrase: '请立刻到办公室',
urgent: true,
},
});
record(
'紧急叫人(urgent=true → priority=URGENT,学生端无视上课时段立即展开)',
urgentCall.status === 201 && urgentCall.payload?.data?.priority === 'URGENT',
`status=${urgentCall.status} priority=${urgentCall.payload?.data?.priority ?? '-'}`,
);
const callWithMessage = await api('/calls', {
method: 'POST',
@@ -591,9 +612,9 @@ async function main() {
body: { classId, studentId: studentUser.id, quickPhrase: '请马上来一趟' },
});
record(
'上课时段允许叫人(不受紧急通知 409 限制)',
'上课时段允许叫人(普通叫人同样不受紧急通知 409 限制)',
callDuringClass.status === 201,
`status=${callDuringClass.status}`,
`status=${callDuringClass.status} priority=${callDuringClass.payload?.data?.priority ?? '-'}`,
);
const studentCalls = await api('/calls', {
@@ -614,11 +635,11 @@ async function main() {
);
// 清理:删除本次叫人产生的通知
for (const created of [callByTeacher, callWithMessage, callDuringClass]) {
for (const created of [callByTeacher, urgentCall, callWithMessage, callDuringClass]) {
const id = created.payload?.data?.id;
if (id) await api(`/notifications/${id}`, { method: 'DELETE', token: teacherToken });
}
record('清理叫人测试数据', true, '已删除 3 条叫人通知');
record('清理叫人测试数据', true, '已删除 4 条叫人通知');
}
// ---------------------------------------------------------------- 6.4 班级角色权限矩阵
@@ -13,6 +13,8 @@ export const createCallSchema = z
quickPhrase: z.string().trim().max(64).optional(),
/** 自定义消息(可选;与 quickPhrase 至少填一个) */
message: z.string().trim().max(200).optional(),
/** 紧急叫人:落库 URGENT,学生端无视上课时段立即展开;默认普通(只进队列) */
urgent: z.boolean().optional(),
})
.refine((data) => Boolean(data.studentId), { message: '请选择学生', path: ['studentId'] })
.refine((data) => Boolean(data.message || data.quickPhrase), {
@@ -15,7 +15,11 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
*
* 设计:复用通知表落库(学生端通知中心可见、已读状态天然可用、离线也能看到历史),
* 同时额外广播 `call:new` 到该学生的 user 房间 —— 客户端据此把消息标记为
* "叫人"类型,无论是否在上课都立即展开显示。
* "叫人"类型(徽标/按钮/尺寸),**是否立即展开则由 priority 决定**:
* - 紧急叫人(urgent=true → URGENT):无视上课时段立即展开;
* - 普通叫人(默认 → HIGH):按普通通知处理,上课时段只进队列、下课后弹出,
* 课间则先显示胶囊、点击后展开。
* 两种情况都不会被"上课时段发布紧急通知"的 409 拦截(老师确实需要学生过来)。
*/
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertCanPublishContent(user, input.classId);
@@ -42,8 +46,9 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
classId: input.classId,
title: buildCallTitle(studentName, teacherName),
content: message,
// 叫人属于需要立即响应的消息,用 HIGH 级别(不会触发上课时段 409 拦截)
priority: 'HIGH',
// 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH
// (不会触发上课时段 409 拦截,但学生端按普通通知排队处理)
priority: input.urgent ? 'URGENT' : 'HIGH',
createdBy: user.sub,
},
include: { creator: creatorSelect, reads: true },
@@ -58,6 +63,6 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
logger.info(`叫人:${teacherName} → ${studentName}(${message})`);
logger.info(`${input.urgent ? '紧急叫人' : '叫人'}:${teacherName} → ${studentName}(${message})`);
return dto;
}
+6
View File
@@ -293,6 +293,12 @@ export interface CreateNotificationRequest {
export interface CreateCallRequest {
classId: string;
studentId: string;
/**
* 是否"紧急叫人":
* - true:落库为 URGENT,学生端灵动岛无视上课时段立即展开(与紧急通知同等待遇);
* - 省略/false:普通叫人(默认),按普通通知处理 —— 上课时段只进队列、不打断课堂,下课后弹出。
*/
urgent?: boolean;
/** 快捷短语(与 message 至少填一个) */
quickPhrase?: string;
/** 自定义消息(优先于快捷短语) */
@@ -65,12 +65,15 @@ const callStudentId = ref('');
const callStudents = ref<StudentDto[]>([]);
const callQuickPhrase = ref<string>(CALL_QUICK_PHRASES[0] ?? '');
const callMessage = ref('');
/** 紧急叫人:无视上课时段立即展开;默认普通(只进队列,课后弹出) */
const callUrgent = ref(false);
async function openCall(): Promise<void> {
callClassId.value = filter.classId || classes.value[0]?.id || '';
callStudentId.value = '';
callMessage.value = '';
callQuickPhrase.value = CALL_QUICK_PHRASES[0] ?? '';
callUrgent.value = false;
callStudents.value = [];
callVisible.value = true;
if (callClassId.value) await loadCallStudents();
@@ -103,10 +106,11 @@ async function submitCall(): Promise<void> {
const created = await callApi.create({
classId: callClassId.value,
studentId: callStudentId.value,
urgent: callUrgent.value,
...(callMessage.value.trim() ? { message: callMessage.value.trim() } : {}),
...(callQuickPhrase.value.trim() ? { quickPhrase: callQuickPhrase.value.trim() } : {}),
});
ElMessage.success(`已通知:${created.title}`);
ElMessage.success(`已${callUrgent.value ? '紧急' : ''}通知:${created.title}`);
callVisible.value = false;
} finally {
callSending.value = false;
@@ -382,11 +386,21 @@ onUnmounted(() => {
placeholder="可选;填写后优先于快捷短语"
/>
</el-form-item>
<el-form-item label="级别">
<el-radio-group v-model="callUrgent">
<el-radio-button :value="false">普通(不打断课堂)</el-radio-button>
<el-radio-button :value="true">紧急(立即展开)</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<el-alert
type="warning"
:type="callUrgent ? 'error' : 'warning'"
:closable="false"
title="学生端桌面会立即浮出「请 XXX 同学找 XXX 老师」,上课时段也照常弹出"
:title="
callUrgent
? '紧急叫人:学生端桌面无视上课时段立即展开,需学生点「收到」'
: '普通叫人:学生端课间先显示胶囊、点击展开;上课时段只排队,下课后弹出'
"
/>
<template #footer>
<el-button @click="callVisible = false">取消</el-button>
+17 -4
View File
@@ -123,12 +123,13 @@ onMounted(async () => {
const callVisible = ref(false);
const callSending = ref(false);
const callTarget = ref<StudentDto | null>(null);
const callForm = reactive({ quickPhrase: '', message: '' });
const callForm = reactive({ quickPhrase: '', message: '', urgent: false });
function openCall(row: StudentDto): void {
callTarget.value = row;
callForm.quickPhrase = CALL_QUICK_PHRASES[0] ?? '';
callForm.message = '';
callForm.urgent = false;
callVisible.value = true;
}
@@ -153,10 +154,11 @@ async function submitCall(): Promise<void> {
const created = await callApi.create({
classId: target.classId,
studentId: target.id,
urgent: callForm.urgent,
...(callForm.message.trim() ? { message: callForm.message.trim() } : {}),
...(callForm.quickPhrase.trim() ? { quickPhrase: callForm.quickPhrase.trim() } : {}),
});
ElMessage.success(`已通知 ${target.name}:${created.title}`);
ElMessage.success(`已${callForm.urgent ? '紧急' : ''}通知 ${target.name}:${created.title}`);
callVisible.value = false;
} finally {
callSending.value = false;
@@ -235,8 +237,11 @@ async function submitCall(): Promise<void> {
class="call-alert"
>
<template #default>
发送后学生端桌面会立即浮出「请 {{ callTarget.name }} 同学找 XXX 老师」,
无需学生刷新;上课时段也会立刻显示。
发送后学生端桌面会浮出「请 {{ callTarget.name }} 同学找 XXX 老师」;
<b>紧急</b>
叫人无视上课时段立即展开,
<b>普通</b>
叫人课间先显示胶囊、上课时段只排队。
</template>
</el-alert>
@@ -256,6 +261,14 @@ async function submitCall(): Promise<void> {
</div>
</div>
<div class="call-section">
<div class="call-label">级别(默认普通:不打断课堂)</div>
<el-radio-group v-model="callForm.urgent">
<el-radio-button :value="false">普通</el-radio-button>
<el-radio-button :value="true">紧急</el-radio-button>
</el-radio-group>
</div>
<div class="call-section">
<div class="call-label">自定义消息(可选,优先于快捷短语)</div>
<el-input