feat: 客户端灵动岛 + 上课时段紧急通知二次确认,并修复打包/校验脚本在 Electron 宿主下的多个缺陷
## 客户端灵动岛(动态岛)
- 新增独立透明置顶窗口:桌面中上方常驻、不占任务栏、不抢焦点(268x44 胶囊 / 404x316 详情 / 424x344 紧急)
- 三种形态与动画:隐藏 ↔ 胶囊 ↔ 详情,尺寸用 easeOutCubic 260ms 逐帧缓动、透明度单独淡入淡出
(尺寸与淡入使用各自动画令牌,避免互相打断导致窗口卡在胶囊尺寸)
- 行为:普通通知变「新消息 · 共 N 条」胶囊,点击展开详情;上课时段自动隐藏并进入待发队列,
下课后自动弹出;紧急通知无论是否上课立即展开(红色呼吸光晕 + 紧急角标,无需点击)
- 上课状态来自 GET /api/schedules/current(10s 心跳 + 下课时长定时器),
新增 src/main/island.ts(控制器/IPC)、src/preload/island.ts、src/island/IslandApp.vue、
src/renderer/island/bridge.ts、island.html 与多页 Vite 构建
## 上课时段发布紧急通知的二次确认
- 服务端:priority=URGENT 且当前处于上课时间段且未带 confirmDuringClass 时返回
409 URGENT_DURING_CLASS(携带 current/next/week/serverTime),绕过界面调接口同样被拦截
- 新增 GET /api/schedules/current(inClass/current/next,at 参数用于诊断与测试)
- Web 管理端:全屏警告遮罩(UrgentClassWarning.vue),显示正在上的课时段与待发布标题,
确认按钮带 3 秒倒计时(圆环进度,倒计时结束前禁用),取消即不发;并发命中 409 同样兜底弹窗
## 打包与验证脚本修复(Electron 宿主环境)
- scripts/lib/node-runtime.mjs:定位真正的 node.exe。此前经 pnpm 运行时 process.execPath 指向
Electron,dist:server 会把 Electron 复制成安装包内的 node.exe,dist:win 会让 electron-builder 报
Unknown argument: .../cli.js(改为显式使用真实 Node)
- scripts/lib/electron-env.mjs:启动 Electron 前清理继承的 ELECTRON_RUN_AS_NODE(否则 Electron 退化为
纯 Node,require('electron') 直接 MODULE_NOT_FOUND),并从 path.txt 定位 electron.exe
(process.versions.electron 存在时 import 'electron' 返回的是 API 对象而非路径)
- scripts/dist-server.mjs:新增构建产物新鲜度校验(曾出现 server 已重建但打进安装包的 shared/dist 是旧的,
安装后服务启动即报 does not provide an export named MAX_TERM_WEEK)
- scripts/ui-smoke:新增 live-probe.mjs 真实制造/清理上课时段,Electron 驱动真实点击断言全屏警告文案、
按钮禁用与 3 秒倒计时时长(实测 2966ms),测试后自动删除探针课表与测试通知
## 验证结果
- pnpm verify:e2e 62/62(新增上课时段拦截、二次确认后发布、普通通知不受限等 8 项)
- pnpm verify:web 8/8(新增上课时段紧急通知全屏警告 + 倒计时)
- pnpm verify:desktop 18/18(新增灵动岛窗口/上课隐藏/下课弹出/紧急立即展开/胶囊点击展开 + 截图)
- 打包后 EXE 冒烟 15/15(packaged: true,退出码 0)
- 服务端安装程序升级安装:.env 与数据库保持不变,/api/schedules/current 正常
- pnpm lint / format:check / typecheck 全部通过
This commit is contained in:
1 parent
5545ef6f55
commit
3a9f8812fc
40 files changed
+3062
-149
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import path from 'node:path';
|
||||
import { BrowserWindow, app, shell } from 'electron';
|
||||
import { registerIpcHandlers } from './ipc.js';
|
||||
import { island, registerIslandIpc } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
import { runSmokeTest } from './smoke.js';
|
||||
|
||||
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
|
||||
@@ -75,10 +77,25 @@ if (!gotLock) {
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
registerIpcHandlers();
|
||||
registerIslandIpc();
|
||||
|
||||
mainWindow = createWindow();
|
||||
await loadRenderer(mainWindow);
|
||||
|
||||
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
|
||||
try {
|
||||
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
|
||||
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
|
||||
island.setOpenAppHandler(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('灵动岛初始化失败(不影响主功能)', error);
|
||||
}
|
||||
|
||||
if (isSmokeTest) {
|
||||
try {
|
||||
await runSmokeTest(mainWindow);
|
||||
@@ -90,9 +107,14 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
island.destroy();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
island.destroy();
|
||||
});
|
||||
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
import path from 'node:path';
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron';
|
||||
import type { IslandMode, IslandNotification, IslandState } from '@classhelper/shared';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 灵动岛(Dynamic Island)主进程控制器。
|
||||
*
|
||||
* 形态:一个独立的、无边框、透明、置顶、不占任务栏、不抢焦点的小窗口,
|
||||
* 固定在主显示器工作区顶部居中("桌面中上方")。
|
||||
*
|
||||
* 行为(对应产品需求):
|
||||
* - 收到教师通知:变形为"新消息"胶囊;点击展开显示详情
|
||||
* - 上课时间段:非紧急通知不弹出(自动隐藏),进入待发队列
|
||||
* - 下课后:自动在桌面中上方弹出队列中的通知详情
|
||||
* - 紧急通知:无论是否在上课,立刻展开显示详情,无需点击
|
||||
*/
|
||||
|
||||
const SIZES: Record<IslandMode, { width: number; height: number }> = {
|
||||
hidden: { width: 268, height: 44 },
|
||||
pill: { width: 268, height: 44 },
|
||||
expanded: { width: 404, height: 316 },
|
||||
};
|
||||
|
||||
const URGENT_SIZE = { width: 424, height: 344 };
|
||||
|
||||
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
|
||||
const TIMEOUTS = {
|
||||
pill: 15_000,
|
||||
afterClassExpanded: 14_000,
|
||||
afterClassPill: 25_000,
|
||||
expanded: 20_000,
|
||||
urgent: 45_000,
|
||||
};
|
||||
|
||||
export interface IslandPushContext {
|
||||
inClass?: boolean;
|
||||
currentPeriodEnd?: string | null;
|
||||
week?: number;
|
||||
}
|
||||
|
||||
export interface IslandClassStatePayload {
|
||||
inClass: boolean;
|
||||
currentPeriodEnd?: string | null;
|
||||
week?: number;
|
||||
}
|
||||
|
||||
type IslandActionName = 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app';
|
||||
|
||||
interface IslandActionPayload {
|
||||
action: IslandActionName;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
class IslandController {
|
||||
private win: BrowserWindow | null = null;
|
||||
private rendererUrl: string | null = null;
|
||||
private ready = false;
|
||||
private pendingState: IslandState | null = null;
|
||||
private collapseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */
|
||||
private boundsToken = 0;
|
||||
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
|
||||
private fadeToken = 0;
|
||||
|
||||
private state: IslandState = {
|
||||
mode: 'hidden',
|
||||
active: null,
|
||||
queued: [],
|
||||
inClass: false,
|
||||
currentPeriodEnd: null,
|
||||
reason: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
/** 灵动岛展开时点击"打开应用"交给外部处理 */
|
||||
private openAppHandler: (() => void) | null = null;
|
||||
|
||||
setOpenAppHandler(handler: () => void): void {
|
||||
this.openAppHandler = handler;
|
||||
}
|
||||
|
||||
async init(rendererUrl: string | null): Promise<void> {
|
||||
this.rendererUrl = rendererUrl;
|
||||
if (this.win && !this.win.isDestroyed()) return;
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...this.computeBounds(SIZES.hidden),
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
// 不抢焦点:通知不应该打断学生正在做的事(键盘焦点仍留在原应用)
|
||||
focusable: false,
|
||||
alwaysOnTop: true,
|
||||
type: 'toolbar',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/island.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
win.setAlwaysOnTop(true, 'screen-saver');
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
win.setIgnoreMouseEvents(false);
|
||||
win.on('closed', () => {
|
||||
this.win = null;
|
||||
this.ready = false;
|
||||
});
|
||||
|
||||
this.win = win;
|
||||
|
||||
// 开发/冒烟时把灵动岛渲染进程的日志转发到主进程,便于排查"窗口空白"之类问题
|
||||
win.webContents.on('console-message', (...args) => {
|
||||
const first = args[0];
|
||||
const isEventObject = first && typeof first === 'object' && 'message' in first;
|
||||
const level = isEventObject ? first.level : args[1];
|
||||
const message = isEventObject ? first.message : args[2];
|
||||
const isProblem = level === 'error' || level === 'warning' || level === 2 || level === 3;
|
||||
if (isProblem || process.env.ELECTRON_SMOKE_TEST === '1') {
|
||||
logger.info(`[island-renderer] ${String(message)}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (rendererUrl) {
|
||||
await win.loadURL(rendererUrl);
|
||||
} else {
|
||||
await win.loadFile(path.join(__dirname, '../renderer/island.html'));
|
||||
}
|
||||
this.ready = true;
|
||||
if (this.pendingState) {
|
||||
this.emit(this.pendingState);
|
||||
this.pendingState = null;
|
||||
}
|
||||
// 首次进入时同步一次当前状态,保证渲染进程不会停在初始默认值
|
||||
this.emit();
|
||||
logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)');
|
||||
}
|
||||
|
||||
getWindow(): BrowserWindow | null {
|
||||
return this.win;
|
||||
}
|
||||
|
||||
getState(): IslandState {
|
||||
return { ...this.state, queued: [...this.state.queued] };
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ready && Boolean(this.win) && !this.win?.isDestroyed();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 对外行为 */
|
||||
|
||||
/**
|
||||
* 收到一条通知。
|
||||
* - 紧急:立刻展开(无视上课时段)
|
||||
* - 上课中:进队列并保持隐藏(下课后由 setClassState 触发弹出)
|
||||
* - 其它:显示"新消息"胶囊,等待点击展开
|
||||
*/
|
||||
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
|
||||
const inClass = context.inClass ?? this.state.inClass;
|
||||
this.state.inClass = inClass;
|
||||
if (context.currentPeriodEnd !== undefined)
|
||||
this.state.currentPeriodEnd = context.currentPeriodEnd ?? null;
|
||||
|
||||
if (notification.priority === 'URGENT') {
|
||||
// 紧急插播:如果正在展示别的通知,把它放回队列
|
||||
if (this.state.active && this.state.active.id !== notification.id) {
|
||||
this.state.queued.push(this.state.active);
|
||||
}
|
||||
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
|
||||
this.activate(notification, 'urgent', 'expanded');
|
||||
logger.info(`灵动岛紧急插播:${notification.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inClass) {
|
||||
// 上课时段:自动隐藏,暂存待下课后弹出
|
||||
this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification];
|
||||
if (this.state.mode !== 'hidden') {
|
||||
this.setState({ mode: 'hidden', reason: null });
|
||||
}
|
||||
this.state.updatedAt = Date.now();
|
||||
this.emit();
|
||||
logger.info(`灵动岛暂存通知(上课中,下课后弹出):${notification.title}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.mode === 'expanded' && this.state.active) {
|
||||
// 已经展开时,把当前这条挤入队列并显示新的一条
|
||||
this.state.queued.push(this.state.active);
|
||||
}
|
||||
this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification];
|
||||
this.activate(notification, 'new', 'pill');
|
||||
}
|
||||
|
||||
/** 上课状态变化:进入上课隐藏;下课则自动弹出队列 */
|
||||
setClassState(payload: IslandClassStatePayload): void {
|
||||
const wasInClass = this.state.inClass;
|
||||
this.state.inClass = payload.inClass;
|
||||
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
|
||||
|
||||
if (payload.inClass) {
|
||||
if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏');
|
||||
if (this.state.mode !== 'hidden') {
|
||||
// 上课期间自动隐藏(紧急通知也遵循,避免持续遮挡课堂投影)
|
||||
this.setState({ mode: 'hidden', reason: null });
|
||||
} else {
|
||||
this.state.updatedAt = Date.now();
|
||||
this.emit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasInClass && !payload.inClass) {
|
||||
logger.info('灵动岛:下课,弹出上课期间暂存的通知');
|
||||
this.flushQueueAfterClass();
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.updatedAt = Date.now();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** 下课:弹出队列中最新的一条(展开显示详情),其余保留为"还有 N 条" */
|
||||
flushQueueAfterClass(): void {
|
||||
const next = this.state.queued[this.state.queued.length - 1];
|
||||
if (!next) {
|
||||
this.setState({ mode: 'hidden', reason: null });
|
||||
return;
|
||||
}
|
||||
const urgent = this.state.queued.find((item) => item.priority === 'URGENT');
|
||||
const target = urgent ?? next;
|
||||
this.activate(target, 'after-class', 'expanded');
|
||||
}
|
||||
|
||||
handleAction(payload: IslandActionPayload): void {
|
||||
switch (payload.action) {
|
||||
case 'expand':
|
||||
if (this.state.active) {
|
||||
this.setState({ mode: 'expanded' });
|
||||
this.scheduleCollapse(
|
||||
this.state.active.priority === 'URGENT' ? TIMEOUTS.urgent : TIMEOUTS.expanded,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'collapse': {
|
||||
const next = this.state.queued[this.state.queued.length - 1];
|
||||
if (next) {
|
||||
this.setState({ mode: 'pill', reason: this.state.reason });
|
||||
this.scheduleCollapse(TIMEOUTS.pill);
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dismiss':
|
||||
this.dismissActive();
|
||||
break;
|
||||
case 'mark-read': {
|
||||
const queue = this.state.queued.filter((item) => item.id !== payload.id);
|
||||
this.state.queued = queue;
|
||||
if (this.state.active?.id === payload.id) this.state.active = null;
|
||||
this.state.updatedAt = Date.now();
|
||||
if (this.state.mode === 'hidden') this.emit();
|
||||
else this.dismissActive();
|
||||
break;
|
||||
}
|
||||
case 'open-app':
|
||||
this.openAppHandler?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.clearTimers();
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
this.fadeOut();
|
||||
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clearTimers();
|
||||
if (this.win && !this.win.isDestroyed()) this.win.destroy();
|
||||
this.win = null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 内部实现 */
|
||||
|
||||
private dismissActive(): void {
|
||||
this.clearTimers();
|
||||
if (this.state.active) {
|
||||
this.state.queued = this.state.queued.filter((item) => item.id !== this.state.active?.id);
|
||||
}
|
||||
this.state.active = null;
|
||||
|
||||
const next = this.state.queued[this.state.queued.length - 1];
|
||||
if (next) {
|
||||
// 还有未看的通知:退成胶囊态提示"还有 N 条"
|
||||
this.state.active = next;
|
||||
this.setState({ mode: 'pill', reason: 'after-class' });
|
||||
this.scheduleCollapse(TIMEOUTS.afterClassPill);
|
||||
return;
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
|
||||
this.clearTimers();
|
||||
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
|
||||
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
|
||||
this.state.active = notification;
|
||||
this.state.updatedAt = Date.now();
|
||||
this.setState({ mode, reason });
|
||||
if (notification.priority === 'URGENT') {
|
||||
this.scheduleCollapse(TIMEOUTS.urgent);
|
||||
} else if (reason === 'after-class') {
|
||||
this.scheduleCollapse(TIMEOUTS.afterClassExpanded);
|
||||
} else if (mode === 'pill') {
|
||||
this.scheduleCollapse(TIMEOUTS.pill);
|
||||
} else {
|
||||
this.scheduleCollapse(TIMEOUTS.expanded);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleCollapse(delayMs: number): void {
|
||||
this.clearTimers();
|
||||
this.collapseTimer = setTimeout(() => {
|
||||
this.collapseTimer = null;
|
||||
if (this.state.mode === 'expanded') {
|
||||
const hasMore = this.state.queued.length > 1;
|
||||
if (hasMore) {
|
||||
this.setState({ mode: 'pill' });
|
||||
this.scheduleCollapse(TIMEOUTS.afterClassPill);
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.hide();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.collapseTimer) {
|
||||
clearTimeout(this.collapseTimer);
|
||||
this.collapseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private computeBounds(size: { width: number; height: number }): {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const area = screen.getPrimaryDisplay().workArea;
|
||||
return {
|
||||
x: Math.round(area.x + (area.width - size.width) / 2),
|
||||
y: area.y + 8,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
|
||||
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
|
||||
this.state = { ...this.state, ...patch, updatedAt: Date.now() };
|
||||
this.emit();
|
||||
if (!options.skipWindow) this.syncWindow();
|
||||
}
|
||||
|
||||
private emit(state?: IslandState): void {
|
||||
const payload = state ?? this.getState();
|
||||
if (!this.win || this.win.isDestroyed()) {
|
||||
this.pendingState = payload;
|
||||
return;
|
||||
}
|
||||
if (!this.ready) {
|
||||
this.pendingState = payload;
|
||||
return;
|
||||
}
|
||||
this.win.webContents.send('island:state', payload);
|
||||
}
|
||||
|
||||
private syncWindow(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
|
||||
if (this.state.mode === 'hidden') {
|
||||
this.fadeOut();
|
||||
return;
|
||||
}
|
||||
|
||||
const size =
|
||||
this.state.mode === 'expanded'
|
||||
? this.state.active?.priority === 'URGENT'
|
||||
? URGENT_SIZE
|
||||
: SIZES.expanded
|
||||
: SIZES.pill;
|
||||
|
||||
if (!this.win.isVisible()) {
|
||||
this.win.setOpacity(0);
|
||||
this.win.showInactive();
|
||||
this.animateBounds(size);
|
||||
this.fadeIn();
|
||||
return;
|
||||
}
|
||||
this.animateBounds(size);
|
||||
}
|
||||
|
||||
private fadeIn(): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const token = ++this.fadeToken;
|
||||
let opacity = this.win.getOpacity();
|
||||
const step = (): void => {
|
||||
if (!this.win || this.win.isDestroyed() || token !== this.fadeToken) return;
|
||||
opacity = Math.min(1, opacity + 0.16);
|
||||
this.win.setOpacity(opacity);
|
||||
if (opacity < 1) setTimeout(step, 16);
|
||||
};
|
||||
step();
|
||||
}
|
||||
|
||||
private fadeOut(): void {
|
||||
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return;
|
||||
const token = ++this.fadeToken;
|
||||
let opacity = this.win.getOpacity();
|
||||
const step = (): void => {
|
||||
if (!this.win || this.win.isDestroyed() || token !== this.fadeToken) return;
|
||||
opacity = Math.max(0, opacity - 0.2);
|
||||
this.win.setOpacity(opacity);
|
||||
if (opacity > 0.02) {
|
||||
setTimeout(step, 16);
|
||||
return;
|
||||
}
|
||||
this.win.hide();
|
||||
this.win.setOpacity(1);
|
||||
};
|
||||
step();
|
||||
}
|
||||
|
||||
/** 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果 */
|
||||
private animateBounds(size: { width: number; height: number }): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
const from = this.win.getBounds();
|
||||
const to = this.computeBounds(size);
|
||||
if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return;
|
||||
|
||||
const token = ++this.boundsToken;
|
||||
const duration = 260;
|
||||
const startedAt = Date.now();
|
||||
|
||||
const step = (): void => {
|
||||
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
|
||||
const progress = Math.min(1, (Date.now() - startedAt) / duration);
|
||||
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
||||
const bounds = {
|
||||
x: Math.round(from.x + (to.x - from.x) * eased),
|
||||
y: Math.round(from.y + (to.y - from.y) * eased),
|
||||
width: Math.round(from.width + (to.width - from.width) * eased),
|
||||
height: Math.round(from.height + (to.height - from.height) * eased),
|
||||
};
|
||||
this.win.setBounds(bounds);
|
||||
if (progress < 1) setTimeout(step, 12);
|
||||
};
|
||||
step();
|
||||
}
|
||||
}
|
||||
|
||||
export const island = new IslandController();
|
||||
|
||||
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
|
||||
export function registerIslandIpc(): void {
|
||||
ipcMain.on(
|
||||
'island:push',
|
||||
(_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => {
|
||||
if (!payload?.notification) return;
|
||||
island.pushNotification(payload.notification, payload.context ?? {});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on('island:class-state', (_event, payload: IslandClassStatePayload) => {
|
||||
if (!payload) return;
|
||||
island.setClassState(payload);
|
||||
});
|
||||
|
||||
ipcMain.handle('island:get-state', () => island.getState());
|
||||
|
||||
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
|
||||
if (!payload?.action) return;
|
||||
island.handleAction(payload);
|
||||
});
|
||||
}
|
||||
|
||||
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE };
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 主进程日志(控制台输出)。
|
||||
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
|
||||
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
|
||||
* - 主窗口右上角的连接状态 / 灵动岛状态
|
||||
* 对外可见,避免"出了问题却看不到日志"。
|
||||
*/
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
|
||||
},
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
console.log(`[${timestamp()}] INFO ${message}`, ...args);
|
||||
},
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
console.warn(`[${timestamp()}] WARN ${message}`, ...args);
|
||||
},
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
console.error(`[${timestamp()}] ERROR ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,9 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { IslandNotification } from '@classhelper/shared';
|
||||
import { getDiagnostics } from './ipc.js';
|
||||
import { island } from './island.js';
|
||||
|
||||
interface SmokeResult {
|
||||
name: string;
|
||||
@@ -10,6 +12,177 @@ interface SmokeResult {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
type Recorder = (name: string, ok: boolean, detail?: string) => void;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 灵动岛状态截图留档(便于人工复核外观与动画) */
|
||||
async function captureIsland(name: string): Promise<void> {
|
||||
const dir = process.env.ISLAND_SHOTS_DIR;
|
||||
const win = island.getWindow();
|
||||
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return;
|
||||
try {
|
||||
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.documentElement.style.background = '#1f2733'; document.body.style.background = '#1f2733'; true`,
|
||||
);
|
||||
await sleep(120);
|
||||
const image = await win.webContents.capturePage();
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `${name}.png`), image.toPNG());
|
||||
console.log(`[SMOKE] 灵动岛截图:${path.join(dir, `${name}.png`)}`);
|
||||
} catch (error) {
|
||||
console.warn('[SMOKE] 灵动岛截图失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 打印灵动岛渲染进程的真实 DOM 结构(排查空白窗口) */
|
||||
async function dumpIslandDom(_label: string): Promise<string> {
|
||||
const win = island.getWindow();
|
||||
if (!win || win.isDestroyed()) return 'no-window';
|
||||
try {
|
||||
return await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const app = document.querySelector('#app');
|
||||
const card = document.querySelector('.island-card');
|
||||
return JSON.stringify({
|
||||
appChildren: app ? app.children.length : -1,
|
||||
cardClass: card ? card.className : null,
|
||||
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 60),
|
||||
});
|
||||
})()`,
|
||||
);
|
||||
} catch (error) {
|
||||
return `dump-error: ${(error as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function makeNotification(
|
||||
id: string,
|
||||
priority: IslandNotification['priority'],
|
||||
title: string,
|
||||
): IslandNotification {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
content:
|
||||
'明天下午的数学课改为在实验楼 B205 上课,请同学们提前 10 分钟到达,并携带上次发的练习册。如有疑问请联系课代表。',
|
||||
priority,
|
||||
createdAt: new Date().toISOString(),
|
||||
courseName: '数学',
|
||||
teacherName: '张老师',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 灵动岛行为验证(对应产品需求):
|
||||
* 1. 上课时间段收到普通通知 → 自动隐藏并暂存
|
||||
* 2. 下课后 → 自动在桌面中上方弹出详情
|
||||
* 3. 上课时间段收到紧急通知 → 立即展开显示详情(无需点击)
|
||||
* 4. 非上课时段收到普通通知 → 变成"新消息"胶囊,点击后展开
|
||||
*/
|
||||
async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promise<void> {
|
||||
record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`);
|
||||
if (!island.isReady()) return;
|
||||
|
||||
const islandWindow = island.getWindow();
|
||||
|
||||
// 1) 上课期间:普通通知必须保持隐藏并进入队列
|
||||
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||
island.pushNotification(makeNotification('smoke-normal-in-class', 'NORMAL', '上课期间的普通通知'), {
|
||||
inClass: true,
|
||||
});
|
||||
await sleep(500);
|
||||
const hiddenState = island.getState();
|
||||
record(
|
||||
'上课期间普通通知自动隐藏(暂存待下课弹出)',
|
||||
hiddenState.mode === 'hidden' && hiddenState.queued.length === 1,
|
||||
`mode=${hiddenState.mode} queued=${hiddenState.queued.length}`,
|
||||
);
|
||||
console.log(`[SMOKE] 隐藏态 DOM:${await dumpIslandDom('hidden')}`);
|
||||
|
||||
// 2) 下课:自动弹出暂存通知的详情
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(900);
|
||||
const afterClassState = island.getState();
|
||||
record(
|
||||
'下课后自动弹出通知详情',
|
||||
afterClassState.mode === 'expanded' && afterClassState.active?.id === 'smoke-normal-in-class',
|
||||
`mode=${afterClassState.mode} active=${afterClassState.active?.id ?? '-'} reason=${afterClassState.reason ?? '-'}`,
|
||||
);
|
||||
await captureIsland('island-2-after-class');
|
||||
|
||||
// 3) 上课期间紧急通知:立即展开,无需点击
|
||||
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||
await sleep(300);
|
||||
island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), {
|
||||
inClass: true,
|
||||
});
|
||||
await sleep(900);
|
||||
const urgentState = island.getState();
|
||||
record(
|
||||
'上课期间紧急通知立即展开显示详情(无需点击)',
|
||||
urgentState.mode === 'expanded' &&
|
||||
urgentState.active?.id === 'smoke-urgent-in-class' &&
|
||||
urgentState.reason === 'urgent',
|
||||
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`,
|
||||
);
|
||||
await captureIsland('island-3-urgent');
|
||||
|
||||
// 4) 非上课时段普通通知 → 胶囊态;点击后展开
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(300);
|
||||
island.pushNotification(makeNotification('smoke-normal-free', 'NORMAL', '课间收到的普通通知'), {
|
||||
inClass: false,
|
||||
});
|
||||
await sleep(700);
|
||||
const pillState = island.getState();
|
||||
await captureIsland('island-4-pill');
|
||||
console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`);
|
||||
|
||||
const clicked = await islandWindow?.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const pill = document.querySelector('.island-card.pill:not(.hidden-placeholder)');
|
||||
if (!pill) return false;
|
||||
pill.click();
|
||||
return true;
|
||||
})()`,
|
||||
);
|
||||
await sleep(800);
|
||||
const expandedState = island.getState();
|
||||
record(
|
||||
'普通通知先变"新消息"胶囊,点击后展开详情',
|
||||
pillState.mode === 'pill' && clicked === true && expandedState.mode === 'expanded',
|
||||
`pillMode=${pillState.mode} clicked=${clicked} expandedMode=${expandedState.mode}`,
|
||||
);
|
||||
await captureIsland('island-5-clicked');
|
||||
|
||||
// 5) 灵动岛 DOM 结构自检(确保渲染进程真的画出卡片而不是空白窗口)
|
||||
const domInfo = await islandWindow?.webContents.executeJavaScript(
|
||||
`(() => ({
|
||||
hasCard: Boolean(document.querySelector('.island-card')),
|
||||
title: document.querySelector('.title')?.textContent ?? '',
|
||||
hasActions: Boolean(document.querySelector('.solid-btn')),
|
||||
}))()`,
|
||||
);
|
||||
record(
|
||||
'灵动岛渲染进程正常绘制(卡片/标题/操作按钮)',
|
||||
Boolean(domInfo?.hasCard && domInfo?.title && domInfo?.hasActions),
|
||||
`title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`,
|
||||
);
|
||||
|
||||
// 收尾:隐藏灵动岛,避免影响后续用例
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(300);
|
||||
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
||||
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
||||
@@ -135,6 +308,9 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
);
|
||||
record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
|
||||
|
||||
// ---------------------------------------------------------------- 灵动岛
|
||||
await runIslandChecks(record, results);
|
||||
|
||||
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
|
||||
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
|
||||
const online = await win.webContents.executeJavaScript(
|
||||
|
||||
Reference in new issue
Block a user