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(' ');