feat(desktop,island,shared): 灵动岛支持多条通知竖排列表 + 触摸模式 + 收回动画,产物加凭据门禁
灵动岛列表:多条通知时展开为竖排列表,默认显示 3 条,点「展开更多」铺到屏幕容纳 上限,放不下的收进「更多请前往应用内操作」。卡片高度由主进程与渲染进程各算一次 同一个数 —— 度量表 ISLAND_LIST_METRICS 与排序权重 PRIORITY_RANK 放在 shared, 窗口包围盒随之长高(不够高会把底部按钮裁掉)。 触摸模式:触摸屏上手指既不产生 mousemove、也不移动系统光标,原先两条命中链路都会 让窗口永远停在穿透态、手指点不开岛。现按 navigator.maxTouchPoints 上报后,主进程把 窗口贴合岛体并始终接收输入;非触摸屏机器完全不走这条路。 收回动画:main 先发 island:closing,渲染进程形变收敛后回 closing-done 才淡出, 不再靠猜时长(rAF 被节流时会把卡片缩到一半窗口就没了)。 安全门禁:新增 scripts/check-bundle-secrets.mjs 并接进 build:desktop —— dist 产物会被打进 app.asar 发到每台学生机、且可解包,冒烟凭据一律改由环境变量注入, 产物里出现种子口令即构建失败。
This commit is contained in:
1 parent
d27f9a4b9e
commit
3880eee08e
22 files changed
+2482
-175
No files matched your search
@@ -92,6 +92,11 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
|
||||
|
||||
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
|
||||
export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
||||
// 本机刚录入的作业不再上岛:教室机器上录作业的人就是眼前这台设备的主人,
|
||||
// 再弹一张"新作业"卡片纯属自己通知自己(用户反馈)。
|
||||
if (isLocalHomework(homework)) {
|
||||
return;
|
||||
}
|
||||
window.desktop?.islandPush({
|
||||
notification: {
|
||||
id: `homework-${homework.id}`,
|
||||
@@ -107,6 +112,62 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 本机录入的作业 */
|
||||
|
||||
/**
|
||||
* 本机刚录入的作业指纹。
|
||||
*
|
||||
* 为什么按指纹而不只按 id:`homework:new` 是服务端**先广播、后回响应**的,
|
||||
* 录入接口返回的 id 很可能比实时事件到得还晚 —— 只记 id 会漏掉"事件先到"的那一半,
|
||||
* 于是本机录的作业照样弹上岛。因此在发请求**之前**就按内容记一份指纹,
|
||||
* 回来后补记 id(覆盖事件晚到的情况)。指纹带 TTL,避免长期占用内存。
|
||||
*/
|
||||
const localHomeworkKeys = new Set<string>();
|
||||
let localHomeworkKeysAt = 0;
|
||||
const LOCAL_HOMEWORK_TTL_MS = 60_000;
|
||||
|
||||
/** 作业内容指纹:同一班、同一天、同标题同正文 = 就是本机刚录的那条 */
|
||||
function homeworkFingerprint(homework: {
|
||||
classId?: string | null;
|
||||
title?: string | null;
|
||||
content?: string | null;
|
||||
assignDate?: string | null;
|
||||
}): string {
|
||||
return [
|
||||
homework.classId ?? '',
|
||||
homework.assignDate ?? '',
|
||||
(homework.title ?? '').trim(),
|
||||
(homework.content ?? '').trim(),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
/** 录入前/后登记"这条作业是本机录的"(id 可后补) */
|
||||
export function markHomeworkCreatedLocally(homework: {
|
||||
id?: string | null;
|
||||
classId?: string | null;
|
||||
title?: string | null;
|
||||
content?: string | null;
|
||||
assignDate?: string | null;
|
||||
}): void {
|
||||
const now = Date.now();
|
||||
if (now - localHomeworkKeysAt > LOCAL_HOMEWORK_TTL_MS) localHomeworkKeys.clear();
|
||||
localHomeworkKeysAt = now;
|
||||
localHomeworkKeys.add(homeworkFingerprint(homework));
|
||||
if (homework.id) localHomeworkKeys.add(homework.id);
|
||||
}
|
||||
|
||||
/** 这条作业是不是本机刚录的(供投递前判定与冒烟验证) */
|
||||
export function isLocalHomework(homework: {
|
||||
id?: string | null;
|
||||
classId?: string | null;
|
||||
title?: string | null;
|
||||
content?: string | null;
|
||||
assignDate?: string | null;
|
||||
}): boolean {
|
||||
if (homework.id && localHomeworkKeys.has(homework.id)) return true;
|
||||
return localHomeworkKeys.has(homeworkFingerprint(homework));
|
||||
}
|
||||
|
||||
/**
|
||||
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
|
||||
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
|
||||
@@ -148,15 +209,40 @@ export function subscribeIslandMarkRead(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 灵动岛点了"标为已读"(多条通知的整批)→ 把这一批都在通知中心里标记已读。
|
||||
* 与单条版本同一套兜底:离线时先在本地记为已读。
|
||||
*/
|
||||
export function subscribeIslandMarkAllRead(): void {
|
||||
window.desktop?.onIslandMarkAllRead?.((ids) => {
|
||||
void (async () => {
|
||||
const store = useNotificationStore();
|
||||
await Promise.all(
|
||||
(ids ?? []).map(async (id) => {
|
||||
try {
|
||||
await store.markRead(id);
|
||||
} catch {
|
||||
const target = store.items.find((item) => item.id === id);
|
||||
if (target) target.read = true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/** 具名的课表变更处理器:匿名函数在停止时无法 off 掉,会随"登出→登录"越挂越多 */
|
||||
function onScheduleUpdated(): void {
|
||||
void refreshIslandSchedules().catch(() => undefined);
|
||||
}
|
||||
|
||||
export function startIslandBridge(): void {
|
||||
if (tickTimer) return;
|
||||
|
||||
void refreshIslandSchedules().catch(() => undefined);
|
||||
|
||||
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
|
||||
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, () => {
|
||||
void refreshIslandSchedules().catch(() => undefined);
|
||||
});
|
||||
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
|
||||
|
||||
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
|
||||
refreshTimer = setInterval(() => {
|
||||
@@ -164,9 +250,31 @@ export function startIslandBridge(): void {
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止桥接:退出登录 / 切换账号时必须调用。
|
||||
*
|
||||
* 只清定时器是不够的:
|
||||
* - 订阅要 off,否则每次"登出 → 登录"都会多挂一份监听;
|
||||
* - 本地课表缓存要清空,否则登出后仍会按**上一个班**的课表继续给主进程下发"上课/下课"
|
||||
* (灵动岛按陈旧课表隐藏或弹出),换班登录时第一轮刷新回来之前用的也是旧课表。
|
||||
*/
|
||||
export function stopIslandBridge(): void {
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
tickTimer = null;
|
||||
refreshTimer = null;
|
||||
|
||||
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
|
||||
schedules = [];
|
||||
currentPeriod = null;
|
||||
inClass = false;
|
||||
|
||||
// 把"不在上课"同步给主进程:否则主进程还记着上一个会话的"上课中",
|
||||
// 重新登录后若第一轮课表刷新失败(离线且无缓存),灵动岛会继续按上课态把通知压着不弹,
|
||||
// 直到下一次刷新成功为止。这里主动对齐一次,代价是登出瞬间可能补弹一条此前被暂存的通知。
|
||||
window.desktop?.islandSetClassState({
|
||||
inClass: false,
|
||||
currentPeriodEnd: null,
|
||||
week: useAppStore().currentWeek,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user