feat(desktop+web+server): ClassIsland 风格 UI、灵动岛动画/交互修复、作业上岛与"叫人"功能
## 灵动岛修复与动画优化(用户反馈) - **收回不再出现"方框"闪烁**:卡片改为**固定逻辑尺寸 + 顶部居中锚定**, 窗口只负责露出/裁切透明区域。此前卡片随窗口拉伸,形变过程中会看到被拉扁的圆角方块, 胶囊里的铃铛/文字还会被垂直居中而"从上跳到下";两条问题同源,现已消除。 冒烟用 15 次采样断言卡片尺寸恒为固定值(`396x308` / `260x38`),一旦出现中间值即失败。 - **点击屏幕任意位置收回**:展开态临时 `setFocusable(true)` 并取焦,点到别处即失焦 → 自动回缩为胶囊; 收起后立即释放可聚焦性。断言覆盖 `focusable=true → 失焦后 mode=pill、focusable=false`。 - 动画继续打磨:危险/叫人形态改为**内部光晕**(卡片四周再留 4px 透明边,彻底避免窗口边界裁切出硬边)、 尖角与圆角统一、形态尺寸各异(胶囊 268×44 / 详情 404×316 / 紧急 424×344 / 叫人 440×360)。 ## 灵动岛"标为已读"真正同步通知中心 - 新增链路:灵动岛 `mark-read` → 主进程 `setMarkReadHandler` → 主窗口渲染进程 → `notificationApi.markRead` + 刷新 store(未读红点即时减少)。 - 真机链路断言:点击"标为已读"后 `read=false → true`、未读数 `1 → 0`(此前只从灵动岛队列移除)。 ## 作业发布也上岛 - `homework:new` → 灵动岛 `kind=homework`:胶囊显示"新作业"(青蓝描边 + 书本图标), 展开显示作业说明与"截止时间:…"。 ## 新增"叫人"功能 - 服务端:新模块 `modules/calls`(`POST /api/calls`)——校验班级可写 + 学生属于该班, 落库一条 HIGH 级通知(进通知中心、可用已读状态),广播 `notification:new` 到班级房间, 并定向 `call:new` 给被叫学生。 - 共享层:`CALL_QUICK_PHRASES`(8 条快捷短语)、`buildCallTitle()`("请 XXX 同学找 XXX 老师",老师名已含"老师"不重复)、 `ServerToClientEvents['call:new']`、`CreateCallRequest`。 - Web 管理端:学生管理列表新增 **叫人** 按钮 → 弹窗显示对象、快捷短语(点选/取消)、自定义消息(优先),发送即通知。 - 客户端:`call:new` → 灵动岛 `kind=call`,**上课时段也立即展开**(老师正在等这名学生), 琥珀金卡片 +"叫人"徽标 +"收到"按钮,90 秒后自动收起。 ## 客户端界面改为 ClassIsland 同款 Fluent 风格 - 主题令牌:Mica 浅底 `#f3f3f3`、亚克力卡片 `rgba(255,255,255,.7)` + `backdrop-filter`、 强调色 `#0F6CBD`、半透明描边、卡片 8px / 控件 4px 圆角、极轻阴影。 - Element Plus 变量整体对齐(`--el-color-primary`、圆角、表头/行分隔、滚动条等)。 - 侧边导航改为 Fluent NavigationView 观感:白色半透明底、选中项强调色淡底 + 左侧 3px 强调条; 品牌标改为强调色渐变圆角方块。附客户端五个页面实拍(`docs/screenshots/client/`)。 ## 验证 - `pnpm verify:e2e` **68/68**(新增 6 项叫人:201 + 标题模板、自定义消息优先、上课时段允许、学生 403、通知中心可见、清理) - `pnpm verify:desktop` **33/33**(新增:卡片固定尺寸、点击任意处收回、标为已读同步、作业上岛、叫人上课中立即展开、真实通知链路) - `pnpm verify:web` **16/16**(新增叫人入口:弹窗、8 条快捷短语、自定义输入、短语可选中) - `pnpm lint` / `format:check` / `typecheck` 全部通过;服务端安装包已重打并安装验证
This commit is contained in:
1 parent
f759aac0a2
commit
219f6ae83a
28 files changed
+1555
-156
No files matched your search
@@ -1,10 +1,10 @@
|
||||
import fs from 'node:fs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import type { BrowserWindow, NativeImage } from 'electron';
|
||||
import type { IslandNotification } from '@classhelper/shared';
|
||||
import { getDiagnostics } from './ipc.js';
|
||||
import { island, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
|
||||
|
||||
interface SmokeResult {
|
||||
name: string;
|
||||
@@ -87,9 +87,10 @@ async function captureIsland(
|
||||
name: string,
|
||||
expected: { width: number; height: number },
|
||||
): Promise<IslandShotStats | null> {
|
||||
const dir = process.env.ISLAND_SHOTS_DIR;
|
||||
// 截图统计总是执行(像素断言不依赖环境变量);ISLAND_SHOTS_DIR 只控制是否落盘留档
|
||||
const dir = process.env.ISLAND_SHOTS_DIR ?? '';
|
||||
const win = island.getWindow();
|
||||
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return null;
|
||||
if (!win || win.isDestroyed() || !win.isVisible()) return null;
|
||||
try {
|
||||
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
|
||||
await win.webContents.executeJavaScript(
|
||||
@@ -100,9 +101,11 @@ async function captureIsland(
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, `${name}.png`);
|
||||
fs.writeFileSync(filePath, image.toPNG());
|
||||
const filePath = dir ? path.join(dir, `${name}.png`) : '';
|
||||
if (filePath) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(filePath, image.toPNG());
|
||||
}
|
||||
|
||||
const size = image.getSize();
|
||||
const stats: IslandShotStats = {
|
||||
@@ -348,6 +351,131 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
`chevronClicked=${chevronClicked} mode=${afterChevron.mode}`,
|
||||
);
|
||||
|
||||
// 4.4) 收回过程中卡片不能出现"被拉扁的方框":
|
||||
// 采样卡片尺寸,必须始终是固定的胶囊/详情尺寸之一,绝不出现中间值。
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(700);
|
||||
const shrinkSamples: string[] = [];
|
||||
const shrinkSampler = setInterval(() => {
|
||||
void islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const node = document.querySelector('.island-card');
|
||||
if (!node) return '';
|
||||
const rect = node.getBoundingClientRect();
|
||||
return Math.round(rect.width) + 'x' + Math.round(rect.height);
|
||||
})()`,
|
||||
)
|
||||
.then((value: string) => {
|
||||
if (value) shrinkSamples.push(value);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 40);
|
||||
island.handleAction({ action: 'collapse' });
|
||||
await sleep(700);
|
||||
clearInterval(shrinkSampler);
|
||||
await sleep(120);
|
||||
const allowedSizes = new Set(['396x308', '416x336', '432x352', '260x38']);
|
||||
const stretched = [...new Set(shrinkSamples)].filter((size) => !allowedSizes.has(size));
|
||||
record(
|
||||
'收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)',
|
||||
shrinkSamples.length >= 3 && stretched.length === 0,
|
||||
`采样=${shrinkSamples.length} 尺寸集合=[${[...new Set(shrinkSamples)].join(',')}] 异常尺寸=[${stretched.join(',')}]`,
|
||||
);
|
||||
|
||||
// 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(700);
|
||||
const focusable = islandWindow?.isFocusable() ?? false;
|
||||
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
|
||||
islandWindow?.emit('blur');
|
||||
await sleep(700);
|
||||
const afterBlur = island.getState();
|
||||
record(
|
||||
'点击屏幕任意位置回缩为胶囊(展开时可聚焦 + 失焦收起)',
|
||||
focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill',
|
||||
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
|
||||
);
|
||||
|
||||
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示截止时间
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(400);
|
||||
island.pushNotification(
|
||||
{
|
||||
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
|
||||
kind: 'homework',
|
||||
subtitle: '截止时间:2026-09-15 22:00',
|
||||
teacherName: '张老师',
|
||||
},
|
||||
{ inClass: false },
|
||||
);
|
||||
await sleep(700);
|
||||
const homeworkState = island.getState();
|
||||
const homeworkDom = await islandWindow?.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card.pill.homework');
|
||||
return { cls: card?.className ?? '', title: card?.querySelector('.pill-title')?.textContent?.trim() ?? '' };
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
'新作业发布也上岛(胶囊带"新作业"样式)',
|
||||
homeworkState.mode === 'pill' &&
|
||||
homeworkState.active?.kind === 'homework' &&
|
||||
(homeworkDom?.title ?? '').includes('新作业'),
|
||||
`mode=${homeworkState.mode} kind=${homeworkState.active?.kind} 胶囊标题="${homeworkDom?.title ?? '-'}" 类名=${homeworkDom?.cls ?? '-'}`,
|
||||
);
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(700);
|
||||
const homeworkHint = await islandWindow?.webContents.executeJavaScript(
|
||||
`document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? ''`,
|
||||
);
|
||||
record(
|
||||
'作业详情显示截止时间',
|
||||
(homeworkHint ?? '').includes('截止时间'),
|
||||
`附加说明="${homeworkHint ?? '-'}"`,
|
||||
);
|
||||
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
|
||||
|
||||
// 4.7) "叫人":上课时段也立即展开,展示"请 XXX 同学找 XXX 老师"
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||
await sleep(400);
|
||||
island.pushNotification(
|
||||
{
|
||||
...makeNotification('smoke-call', 'HIGH', '请 王小明 同学找 张老师'),
|
||||
kind: 'call',
|
||||
subtitle: '请尽快前往,收到后点「收到」',
|
||||
teacherName: '张老师',
|
||||
},
|
||||
{ inClass: true },
|
||||
);
|
||||
await sleep(900);
|
||||
const callState = island.getState();
|
||||
const callDom = await islandWindow?.webContents.executeJavaScript(
|
||||
`(() => ({
|
||||
badge: document.querySelector('.island-card.expanded .badge')?.textContent?.trim() ?? '',
|
||||
title: document.querySelector('.island-card.expanded .title')?.textContent?.trim() ?? '',
|
||||
solid: document.querySelector('.island-card.expanded .solid-btn')?.textContent?.trim() ?? '',
|
||||
hint: document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? '',
|
||||
cls: document.querySelector('.island-card.expanded')?.className ?? '',
|
||||
}))()`,
|
||||
);
|
||||
record(
|
||||
'叫人消息上岛(上课时段也立即展开,无需点击)',
|
||||
callState.mode === 'expanded' &&
|
||||
callState.active?.kind === 'call' &&
|
||||
callState.reason === 'call' &&
|
||||
callState.inClass === true &&
|
||||
(callDom?.title ?? '').includes('请') &&
|
||||
(callDom?.title ?? '').includes('找'),
|
||||
`mode=${callState.mode} kind=${callState.active?.kind} reason=${callState.reason} 上课中=${callState.inClass} ` +
|
||||
`徽标=${callDom?.badge} 标题="${callDom?.title}" 按钮=${callDom?.solid}`,
|
||||
);
|
||||
await captureIsland('island-8-call', ISLAND_CALL_SIZE);
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(400);
|
||||
|
||||
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
|
||||
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
|
||||
const shotDetails = islandShots.map((shot) => {
|
||||
@@ -403,6 +531,93 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
|
||||
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实通知链路自检(含"标为已读"同步)。
|
||||
*
|
||||
* 与 runIslandChecks 的区别:那里直接调 IPC 验证状态机;这里走完
|
||||
* "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路,
|
||||
* 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。
|
||||
*/
|
||||
async function runIslandRealtimeCheck(
|
||||
win: BrowserWindow,
|
||||
): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> {
|
||||
const scenario = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__?.islandRealtimeScenario) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
return await window.__classhelperSmoke__.islandRealtimeScenario();
|
||||
})()`,
|
||||
);
|
||||
if (!scenario?.ok) {
|
||||
return {
|
||||
delivered: false,
|
||||
deliveryDetail: `投递失败:${scenario?.detail ?? '未知原因'}`,
|
||||
markRead: false,
|
||||
markReadDetail: '未投递成功,跳过',
|
||||
};
|
||||
}
|
||||
|
||||
// 等通知经实时通道到达并弹出胶囊
|
||||
const deadline = Date.now() + 10_000;
|
||||
let state = island.getState();
|
||||
while (Date.now() < deadline) {
|
||||
state = island.getState();
|
||||
if (state.mode === 'pill' && state.active?.id === scenario.notificationId) break;
|
||||
await sleep(120);
|
||||
}
|
||||
const visible = island.getWindow()?.isVisible() ?? false;
|
||||
const delivered = state.mode === 'pill' && state.active?.id === scenario.notificationId && visible;
|
||||
const deliveryDetail =
|
||||
`通知=${scenario.notificationId} 客户端判定上课中=${scenario.inClass} ` +
|
||||
`灵动岛 mode=${state.mode} 可见=${visible}`;
|
||||
|
||||
// 扩展灵动岛 → 点"标为已读" → 通知中心应同步为已读
|
||||
let markRead = false;
|
||||
let markReadDetail = '未执行';
|
||||
if (delivered) {
|
||||
const before = await win.webContents.executeJavaScript(
|
||||
`(async () => window.__classhelperSmoke__.islandReadState(${JSON.stringify(scenario.notificationId)}))()`,
|
||||
);
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(700);
|
||||
const clicked = await island.getWindow()?.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const button = Array.from(document.querySelectorAll('.island-card.expanded .ghost-btn')).find((node) =>
|
||||
(node.textContent ?? '').includes('标为已读'),
|
||||
);
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
})()`,
|
||||
);
|
||||
await sleep(900);
|
||||
const after = await win.webContents.executeJavaScript(
|
||||
`(async () => window.__classhelperSmoke__.islandReadState(${JSON.stringify(scenario.notificationId)}))()`,
|
||||
);
|
||||
markRead = clicked === true && after?.found === true && after?.read === true;
|
||||
markReadDetail =
|
||||
`点击标为已读=${clicked} 点击前 read=${before?.read}(未读 ${before?.unreadCount})` +
|
||||
` 点击后 read=${after?.read}(未读 ${after?.unreadCount})标题="${after?.title ?? '-'}"`;
|
||||
}
|
||||
|
||||
// 清理:删除本次自检产生的通知,避免污染演示数据
|
||||
const cleanup = await win.webContents
|
||||
.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__?.islandRealtimeCleanup) return { ok: false, detail: '缺少清理钩子' };
|
||||
return await window.__classhelperSmoke__.islandRealtimeCleanup(
|
||||
${JSON.stringify(scenario.notificationId)},
|
||||
${JSON.stringify(scenario.teacherToken)},
|
||||
);
|
||||
})()`,
|
||||
)
|
||||
.catch(() => null);
|
||||
console.log(`[SMOKE] 真实链路清理:${cleanup?.detail ?? '未执行'}`);
|
||||
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(300);
|
||||
|
||||
return { delivered, deliveryDetail, markRead, markReadDetail };
|
||||
}
|
||||
/**
|
||||
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
||||
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
||||
@@ -545,6 +760,11 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
String(online?.detail ?? ''),
|
||||
);
|
||||
|
||||
// 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊;
|
||||
// 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读")
|
||||
const realtime = await runIslandRealtimeCheck(win);
|
||||
record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.delivered, realtime.deliveryDetail);
|
||||
record('灵动岛"标为已读"同步通知中心', realtime.markRead, realtime.markReadDetail);
|
||||
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
|
||||
const navigation = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
|
||||
Reference in new issue
Block a user