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:
LoveChengke committed 2026-10-02 00:25:10 +08:00
1 parent d27f9a4b9e
commit 3880eee08e
22 files changed
+2482 -175

No files matched your search

+18 -1
View File
@@ -3,7 +3,24 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Electron 渲染进程安全策略:只允许加载本地资源与后端 HTTP/WS 连接 --> <!--
内容安全策略(Electron 渲染进程)。
为什么必须有:渲染进程通过 preload 桥能拿到 `getConfig()` 返回的**明文登录令牌**
(代表该班全班身份),因此一旦出现一次注入(未来某个 v-html、某个依赖被投毒),
脚本就能把令牌发到外网。CSP 是唯一能拦住"加载并执行远程脚本"的那一层。
各指令口径:
- default-src/script-src 'self':只允许执行本应用自己的脚本(挡住远程脚本注入);
- style-src 需要 'unsafe-inline':Vue SFC 与 Element Plus 运行时会注入行内样式;
- connect-src 放开 http/https/ws/wss:服务器地址由用户在设置页填写(可能是内网 IP、域名、
HTTPS 反代),无法预先枚举,且连接本身不是"执行代码"的入口;
- object-src/frame-src/form-action 一律关掉:本应用不需要插件、内嵌页面与表单提交。
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
/>
<title>班级小助手</title> <title>班级小助手</title>
</head> </head>
<body> <body>
+5
View File
@@ -3,6 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 与主窗口同一套 CSP(理由见 index.html):灵动岛同样是持有 preload 桥的渲染进程 -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
/>
<title>班级小助手 · 灵动岛</title> <title>班级小助手 · 灵动岛</title>
<style> <style>
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */ /* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
+2 -1
View File
@@ -7,9 +7,10 @@
"main": "dist/main/index.js", "main": "dist/main/index.js",
"scripts": { "scripts": {
"dev": "node scripts/dev.mjs", "dev": "node scripts/dev.mjs",
"build": "npm run build:main && npm run build:renderer", "build": "npm run build:main && npm run build:renderer && npm run check:secrets",
"build:main": "node scripts/build-main.mjs", "build:main": "node scripts/build-main.mjs",
"build:renderer": "vite build", "build:renderer": "vite build",
"check:secrets": "node scripts/check-bundle-secrets.mjs",
"start": "electron .", "start": "electron .",
"smoke": "node scripts/smoke.mjs", "smoke": "node scripts/smoke.mjs",
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* 构建产物凭据自检:`dist` 里不允许出现种子账号 / 口令字面量。
*
* 为什么单独立一条门禁:`dist/main/index.js` 与 `dist/renderer/assets/*.js` 会被 electron-builder
* 打进 `app.asar`,随安装包发到每台学生机,而 **asar 可以直接解包**。
* 冒烟/自检代码里写死 `teacher1 / teacher123` 这类字面量,等于把教师账号随安装包一起发出去
* (历史上真的发生过:`grep teacher123 dist/main/index.js` 命中 3 次)。
*
* 凭据一律由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供
* (`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `ELECTRON_SMOKE_CLASS_CODE` / `..._CLASS_PASSWORD`),
* 那两个脚本不进安装包。
*
* 命中即构建失败(exit 1)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const distDir = path.join(root, 'dist');
/** 明确的种子口令:任何情况下都不该出现在产物里 */
const FORBIDDEN_STRINGS = ['teacher123', 'admin123', 'student123'];
/** 组合形态:把种子班级码 / 默认班级密码当凭据使用的写法 */
const FORBIDDEN_PATTERNS = [
{ label: '种子班级码当凭据', pattern: /["'](?:code|classCode)["']\s*[:=]\s*["']G(?:101|102|203)["']/ },
{
label: '默认班级密码当凭据',
pattern: /["'](?:password|classPassword)["']\s*[:=]\s*["']123456["']/,
},
];
/** 递归收集文件(跳过 sourcemap 与二进制) */
function collectFiles(dir) {
const found = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
found.push(...collectFiles(full));
} else if (/\.(?:js|mjs|cjs|html|json|css)$/i.test(entry.name)) {
found.push(full);
}
}
return found;
}
if (!fs.existsSync(distDir)) {
console.log('[check-secrets] 未找到 dist(尚未构建),跳过凭据自检');
process.exit(0);
}
const hits = [];
for (const file of collectFiles(distDir)) {
const content = fs.readFileSync(file, 'utf8');
const relative = path.relative(root, file);
for (const needle of FORBIDDEN_STRINGS) {
if (content.includes(needle)) hits.push(`${relative}:出现种子口令「${needle}」`);
}
for (const { label, pattern } of FORBIDDEN_PATTERNS) {
if (pattern.test(content)) hits.push(`${relative}:${label}`);
}
}
if (hits.length > 0) {
console.error('[check-secrets] 构建产物里出现了种子凭据(asar 可被解包,不能随包分发):');
for (const hit of hits) console.error(` - ${hit}`);
console.error(
'\n请把凭据改为从环境变量读取(见 scripts/smoke.mjs 的 ELECTRON_SMOKE_USER 等),不要写进源码。',
);
process.exit(1);
}
console.log(`[check-secrets] 通过:${collectFiles(distDir).length} 个产物文件里没有任何种子凭据字面量`);
+55 -1
View File
@@ -12,7 +12,7 @@
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件 * node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked * node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked
*/ */
import { spawn } from 'node:child_process'; import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -24,8 +24,17 @@ const args = process.argv.slice(2);
const dirOnly = args.includes('--dir'); const dirOnly = args.includes('--dir');
const passthrough = args.filter((item) => item !== '--dir'); const passthrough = args.filter((item) => item !== '--dir');
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
// `!tempfile: Unable to create temporary file!`,NSIS 那一步就失败(磁盘并不紧张)。
// 统一指到仓库内的真实目录,避开宿主终端带来的环境差异。
const tmpDir = path.join(repoRoot, '.cache', 'tmp');
fs.mkdirSync(tmpDir, { recursive: true });
const env = { const env = {
...process.env, ...process.env,
TEMP: tmpDir,
TMP: tmpDir,
ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/', ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/',
ELECTRON_BUILDER_BINARIES_MIRROR: ELECTRON_BUILDER_BINARIES_MIRROR:
process.env.ELECTRON_BUILDER_BINARIES_MIRROR ?? process.env.ELECTRON_BUILDER_BINARIES_MIRROR ??
@@ -60,11 +69,56 @@ console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
const runtime = resolveNodeRuntime(); const runtime = resolveNodeRuntime();
console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`); console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
// 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)),
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
const outputDir = path.join(root, 'release');
if (fs.existsSync(outputDir)) {
for (const name of fs.readdirSync(outputDir)) {
if (!/\.(exe|7z|blockmap|yml)$/i.test(name)) continue;
try {
fs.rmSync(path.join(outputDir, name), { force: true });
} catch (error) {
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
}
}
console.log('[dist] 已清理上一轮的安装包 / 归档');
}
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env }); const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
/**
* 把交付产物的完整性标签修正为 Medium(Windows)。
*
* 背景(踩过一次,代价是"客户端装不上、便捷版也双击没反应"):
* 本仓库目录树被宿主沙箱打上了 `Low Mandatory Level` 标签(可继承),
* 因此在仓库内构建出的 exe 自身也带 Low 标签。Windows 会把这类进程降到 Low 完整性,
* 于是 setup.exe 写不进 `%LOCALAPPDATA%`(报"拒绝访问"→ 装不上)、
* 便捷版解不出 `%TEMP%`(→ 起不来);即使装上了,Low 标签的应用文件也会让
* Chromium 的 AppContainer 子进程读不到(0x80000003 秒崩)。
*
* 要点:**只有交付物自身的标签要紧**。归档内部的载荷标签不会被还原
* (装到正常位置得到的是默认 Medium 文件),所以打包完成后再统一修正即可 ——
* 反过来说,不能在打包前把 appOutDir 改成 Medium:那样 7za(自身 Low)
* 会因"不能向上读/写"而无法生成归档。
*/
function normalizeIntegrityLabel(dir) {
if (process.platform !== 'win32') return;
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
try {
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
// 目录自身恢复为"继承父目录的 Low",保证下次构建时 Low 的 7za 仍能写入归档
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
console.log('[dist] 已修正交付物完整性标签为 Medium,目录恢复继承:', dir);
} catch (error) {
console.warn('[dist] 完整性标签修正跳过:', error.message);
}
}
child.on('exit', (code) => { child.on('exit', (code) => {
if (code === 0) { if (code === 0) {
const output = path.join(root, 'release'); const output = path.join(root, 'release');
normalizeIntegrityLabel(output);
console.log('\n[dist] 打包完成,产物目录:', output); console.log('\n[dist] 打包完成,产物目录:', output);
} }
process.exit(code ?? 1); process.exit(code ?? 1);
@@ -169,6 +169,10 @@ function launchSmoke(extraEnv) {
ELECTRON_SMOKE_PROFILE: profileDir, ELECTRON_SMOKE_PROFILE: profileDir,
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '', ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '', ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
// 教师凭据只从这里传入:主进程的冒烟代码从环境变量读,
// 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包)
ELECTRON_SMOKE_USER: process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
ELECTRON_SMOKE_PASSWORD: process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
...extraEnv, ...extraEnv,
}), }),
}); });
+433 -40
View File
@@ -5,9 +5,12 @@ import {
ISLAND_SHADOW_PAD, ISLAND_SHADOW_PAD,
ISLAND_SIZE_DELTA, ISLAND_SIZE_DELTA,
ISLAND_SLIVER_WIDTH, ISLAND_SLIVER_WIDTH,
PRIORITY_RANK,
islandListLayout,
type IslandAppearance, type IslandAppearance,
PRIORITY_LABELS, PRIORITY_LABELS,
formatDate, formatDate,
type IslandListLayout,
type IslandNotification, type IslandNotification,
type IslandNotificationKind, type IslandNotificationKind,
type IslandState, type IslandState,
@@ -39,9 +42,19 @@ const state = ref<IslandState>({
inClass: false, inClass: false,
currentPeriodEnd: null, currentPeriodEnd: null,
reason: null, reason: null,
listExpanded: false,
maxCardHeight: 1024,
updatedAt: 0, updatedAt: 0,
}); });
/**
* 渲染状态直接来自主进程(**与仓库版一致,不再有"收回"快照层**)。
*
* 早先这里有一层 `closing`/`closingSnapshot`:点"知道了 / 标为已读"后把卡片按"胶囊摘要"收回再淡出。
* 那套是自研的形变状态机,实测在触摸屏(窗口贴合岛体、形变时要动窗口)上会露出旧帧/重影,
* 用户明确要求"动画就用仓库里那套"—— 于是整段删掉:收起一律走主进程的 `hide()`(窗口淡出),
* 展开/收缩仍是弹簧形变(仓库机制,见 targetSize/progress)。
*/
const bridge = window.island; const bridge = window.island;
const notification = computed<IslandNotification | null>(() => state.value.active); const notification = computed<IslandNotification | null>(() => state.value.active);
@@ -52,6 +65,48 @@ const isCall = computed(() => kind.value === 'call');
const isHomework = computed(() => kind.value === 'homework'); const isHomework = computed(() => kind.value === 'homework');
const queueCount = computed(() => state.value.queued.length); const queueCount = computed(() => state.value.queued.length);
/**
* 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。
* 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。
*/
const pendingNotifications = computed<IslandNotification[]>(() => {
const list: IslandNotification[] = [];
if (state.value.active) list.push(state.value.active);
for (const item of state.value.queued) {
if (!list.some((existing) => existing.id === item.id)) list.push(item);
}
return list.sort((a, b) => {
const rank = (PRIORITY_RANK[b.priority] ?? 0) - (PRIORITY_RANK[a.priority] ?? 0);
if (rank !== 0) return rank;
return String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''));
});
});
const pendingCount = computed(() => pendingNotifications.value.length);
/**
* 多条消息时的**列表布局**:显示几条、要不要"展开更多"、卡片多高。
* 与主进程用同一个 `islandListLayout`(同一份度量)—— 主进程据此把窗口长高,两边必须算出一个数。
*/
const listLayout = computed<IslandListLayout | null>(() => {
if (mode.value !== 'expanded' || pendingCount.value < 2) return null;
return islandListLayout({
count: pendingCount.value,
fontSize: appearance.value.fontSize,
maxHeight: state.value.maxCardHeight,
expanded: state.value.listExpanded,
});
});
const isListMode = computed(() => listLayout.value !== null);
/** 列表里实际渲染出来的通知 */
const listItems = computed<IslandNotification[]>(() =>
listLayout.value ? pendingNotifications.value.slice(0, listLayout.value.rows) : [],
);
/** 没能展示出来的条数(点"展开更多"能看到一部分,剩下的只能去应用里看) */
const hiddenCount = computed(() => Math.max(0, pendingCount.value - listItems.value.length));
const listHint = computed<IslandListLayout['hint']>(() => listLayout.value?.hint ?? null);
/** 胶囊标题:按消息类型区分(新消息 / 新作业 / 叫人) */ /** 胶囊标题:按消息类型区分(新消息 / 新作业 / 叫人) */
const pillTitle = computed(() => { const pillTitle = computed(() => {
if (isCall.value) return '老师叫你'; if (isCall.value) return '老师叫你';
@@ -72,23 +127,12 @@ const TYPE_LABELS: Record<IslandNotificationKind, string> = {
/** 汇总展示顺序:叫人 > 作业 > 通知(与用户示例一致) */ /** 汇总展示顺序:叫人 > 作业 > 通知(与用户示例一致) */
const TYPE_ORDER: IslandNotificationKind[] = ['call', 'homework', 'notification']; const TYPE_ORDER: IslandNotificationKind[] = ['call', 'homework', 'notification'];
const pendingNotifications = computed<IslandNotification[]>(() => {
const list: IslandNotification[] = [];
if (state.value.active) list.push(state.value.active);
for (const item of state.value.queued) {
if (!list.some((existing) => existing.id === item.id)) list.push(item);
}
return list;
});
/** 这批未处理消息覆盖了哪些类型(按"叫人/作业/通知"顺序) */ /** 这批未处理消息覆盖了哪些类型(按"叫人/作业/通知"顺序) */
const pendingTypes = computed<string[]>(() => { const pendingTypes = computed<string[]>(() => {
const kinds = new Set(pendingNotifications.value.map((item) => item.kind ?? 'notification')); const kinds = new Set(pendingNotifications.value.map((item) => item.kind ?? 'notification'));
return TYPE_ORDER.filter((item) => kinds.has(item)).map((item) => TYPE_LABELS[item]); return TYPE_ORDER.filter((item) => kinds.has(item)).map((item) => TYPE_LABELS[item]);
}); });
const pendingCount = computed(() => pendingNotifications.value.length);
/** 多条消息时把类型摊开:`新消息:叫人/作业/通知(共 3 条)` */ /** 多条消息时把类型摊开:`新消息:叫人/作业/通知(共 3 条)` */
const pillSummaryTitle = computed(() => { const pillSummaryTitle = computed(() => {
if (pendingCount.value <= 1) return pillTitle.value; if (pendingCount.value <= 1) return pillTitle.value;
@@ -122,6 +166,45 @@ const subtitle = computed(() => {
/** 叫人消息下方的附加说明(例如"请到办公室找我") */ /** 叫人消息下方的附加说明(例如"请到办公室找我") */
const callHint = computed(() => notification.value?.subtitle ?? ''); const callHint = computed(() => notification.value?.subtitle ?? '');
/**
* 是否显示「标为已读」。
*
* 作业卡片的 id 是本地合成的 `homework-<作业id>`(见 renderer/island/bridge.ts),
* 库里并没有这条通知,点它会让通知中心去调 `POST /notifications/homework-xxx/read` 并弹「通知不存在」。
* 因此作业卡不提供该按钮(它本来也不是"通知",用「知道了」收起即可)。
* 列表形态下只要**至少有一条真通知**就提供(作业行会被主进程按 kind 过滤掉)。
*/
const canMarkRead = computed(() => {
if (isListMode.value) {
return pendingNotifications.value.some((item) => item.kind !== 'homework');
}
return notification.value?.kind !== 'homework';
});
/** 列表里单行的类型(徽标/图标配色) */
function rowKind(item: IslandNotification): IslandNotificationKind {
return item.kind ?? 'notification';
}
function rowBadgeText(item: IslandNotification): string {
const itemKind = rowKind(item);
if (itemKind === 'call') return '叫人';
if (itemKind === 'homework') return '新作业';
return PRIORITY_LABELS[item.priority] ?? item.priority;
}
function rowBadgeClass(item: IslandNotification): string {
const itemKind = rowKind(item);
if (itemKind === 'call') return 'badge-call';
if (itemKind === 'homework') return 'badge-homework';
return `badge-${item.priority.toLowerCase()}`;
}
function rowSubText(item: IslandNotification): string {
const teacher = item.teacherName ? `${item.teacherName} · ` : '';
return `${teacher}${formatDate(item.createdAt, true)}`;
}
/** 下课补发时提示"其实上课期间就到了" */ /** 下课补发时提示"其实上课期间就到了" */
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass); const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
@@ -150,10 +233,20 @@ function onCardClick(event: MouseEvent): void {
} }
function dismiss(): void { function dismiss(): void {
bridge?.sendAction('dismiss'); // 多条消息时"知道了"是**整批**处理:卡片上就一排按钮,点它就是把这批都放下去
bridge?.sendAction(isListMode.value ? 'dismiss-all' : 'dismiss');
}
/** "展开更多":把列表铺到屏幕放得下的程度(主进程负责把窗口长高) */
function expandList(): void {
bridge?.sendAction('expand-list');
} }
function markRead(): void { function markRead(): void {
if (isListMode.value) {
bridge?.sendAction('mark-all-read');
return;
}
if (notification.value) bridge?.sendAction('mark-read', notification.value.id); if (notification.value) bridge?.sendAction('mark-read', notification.value.id);
} }
@@ -184,6 +277,9 @@ function targetSize(): { width: number; height: number } {
return { width: ISLAND_SLIVER_WIDTH, height: Math.max(12, Math.min(28, height - 22)) }; return { width: ISLAND_SLIVER_WIDTH, height: Math.max(12, Math.min(28, height - 22)) };
} }
if (mode.value !== 'expanded') return { width, height }; if (mode.value !== 'expanded') return { width, height };
// 多条消息的列表:高度由共享的列表布局算出(与主进程算的窗口包围盒同一个数)
const layout = listLayout.value;
if (layout) return { width: width + EXTRA.expanded.width, height: layout.height };
const extra = isCall.value ? EXTRA.call : isUrgent.value ? EXTRA.urgent : EXTRA.expanded; const extra = isCall.value ? EXTRA.call : isUrgent.value ? EXTRA.urgent : EXTRA.expanded;
return { width: width + extra.width, height: height + extra.height }; return { width: width + extra.width, height: height + extra.height };
} }
@@ -324,6 +420,12 @@ const expandedAlpha = computed(() => progress.value * progress.value);
* 岛在**固定窗口**内的定位(照搬 WinIsland:窗口不动,岛在画布内居中/贴边形变)。 * 岛在**固定窗口**内的定位(照搬 WinIsland:窗口不动,岛在画布内居中/贴边形变)。
* 水平:居中锚点用 `left: 50% + translateX(-50%)`;左/右锚点贴边(留阴影余量)。 * 水平:居中锚点用 `left: 50% + translateX(-50%)`;左/右锚点贴边(留阴影余量)。
*/ */
/** 水平锚点(供 CSS 决定胶囊内容贴哪条边:左贴左、右贴右、居中则居中对齐) */
const anchorName = computed<'left' | 'center' | 'right'>(() => {
const position = appearance.value.position;
return position.endsWith('left') ? 'left' : position.endsWith('right') ? 'right' : 'center';
});
const islandStyle = computed(() => { const islandStyle = computed(() => {
const position = appearance.value.position; const position = appearance.value.position;
const style: Record<string, string> = { const style: Record<string, string> = {
@@ -484,6 +586,18 @@ function applyAppearance(next: IslandAppearance | null | undefined): void {
onMounted(async () => { onMounted(async () => {
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY)); window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
/**
* 触摸屏上报:主进程据此把窗口改为**贴合岛体**并始终接收输入。
*
* 为什么必须报:触摸屏上"点不开"是机制性的 —— 打开命中的两条链路(本进程的 mousemove 转发、
* 主进程读系统光标)都以"鼠标指针移动"为前提,而手指触摸既不产生 mousemove、也不移动系统光标,
* 窗口会永远停在 `setIgnoreMouseEvents(true)` 的穿透态,手指点下去只落到桌面。
* Chromium 在带触摸数字化仪的机器上会把 `maxTouchPoints` 置为非 0,用它当判据。
*/
const reportTouchMode = (): void => bridge?.setTouchMode?.(true);
if ((navigator.maxTouchPoints ?? 0) > 0) reportTouchMode();
// 兜底:个别设备 maxTouchPoints 判定不到(或接的是外接触摸屏),真收到触摸事件时再补报一次
window.addEventListener('touchstart', reportTouchMode, { passive: true, once: true });
// 心跳:主进程据此判断本渲染进程是否还活着。一旦卡死/崩溃,主进程会隐藏这扇"幽灵窗口" // 心跳:主进程据此判断本渲染进程是否还活着。一旦卡死/崩溃,主进程会隐藏这扇"幽灵窗口"
// 并重建(否则 Windows 会把最后一帧留在屏幕上:岛看着还在,怎么点都没反应)。 // 并重建(否则 Windows 会把最后一帧留在屏幕上:岛看着还在,怎么点都没反应)。
bridge?.alive?.(); bridge?.alive?.();
@@ -516,6 +630,7 @@ onMounted(async () => {
<div <div
class="island-card" class="island-card"
:class="islandClass" :class="islandClass"
:data-anchor="anchorName"
:data-style="appearance.style" :data-style="appearance.style"
:style="islandStyle" :style="islandStyle"
@click="mode === 'expanded' ? onCardClick($event) : expand()" @click="mode === 'expanded' ? onCardClick($event) : expand()"
@@ -540,6 +655,12 @@ onMounted(async () => {
<!-- 胶囊层(WinIsland compact):进度 > 2/3 时完全退净 --> <!-- 胶囊层(WinIsland compact):进度 > 2/3 时完全退净 -->
<span class="layer pill-layer" :style="{ opacity: pillAlpha }"> <span class="layer pill-layer" :style="{ opacity: pillAlpha }">
<!--
胶囊内容固定成"胶囊自己的几何"(宽 = 外观宽度、贴合停靠边/中线):
卡片在开合时会变宽变窄,若让内容跟着卡片宽度走,文字会先按宽卡片铺开、
再随卡片变窄被省略号收回(用户反馈的"新消息那行字往右跳一下再缩回")。
-->
<span class="pill-inner">
<span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }"> <span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true"> <svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
<path <path
@@ -571,23 +692,31 @@ onMounted(async () => {
<span v-if="pendingCount > 1 && pendingTypes.length > 1" class="pill-types" aria-hidden="true"> <span v-if="pendingCount > 1 && pendingTypes.length > 1" class="pill-types" aria-hidden="true">
<span v-for="item in pendingTypes" :key="item" class="pill-type">{{ item }}</span> <span v-for="item in pendingTypes" :key="item" class="pill-type">{{ item }}</span>
</span> </span>
<span class="pill-chevron" aria-hidden="true"> <span class="pill-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path <path
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2.6" stroke-width="2.6"
stroke-linecap="round" stroke-linecap="round"
d="m9 6 6 6-6 6" d="m9 6 6 6-6 6"
/> />
</svg> </svg>
</span>
</span> </span>
</span> </span>
<!-- 展开层(WinIsland expanded):进度²淡入 --> <!-- 展开层(WinIsland expanded):进度²淡入 -->
<span class="layer expanded-layer" :style="{ opacity: expandedAlpha }"> <span
class="layer expanded-layer"
:class="{ 'list-mode': isListMode }"
:style="{ opacity: expandedAlpha }"
>
<header class="head"> <header class="head">
<span class="head-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }"> <span
class="head-icon"
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, list: isListMode }"
>
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true"> <svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
<path <path
fill="currentColor" fill="currentColor"
@@ -608,11 +737,19 @@ onMounted(async () => {
</svg> </svg>
</span> </span>
<div class="head-meta"> <div class="head-meta">
<span class="badge" :class="badgeClass">{{ badgeText }}</span> <span class="badge" :class="isListMode ? 'badge-normal' : badgeClass">
<span v-if="isCall" class="live-dot call">老师正在等你</span> {{ isListMode ? `共 ${pendingCount} 条` : badgeText }}
<span v-else-if="isUrgent" class="live-dot">需立即查看</span> </span>
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span> <template v-if="isListMode">
<span class="head-sub">{{ subtitle }}</span> <span v-if="pendingTypes.length > 1" class="head-sub">{{ pendingTypes.join(' / ') }}</span>
<span v-else class="head-sub">按重要程度排列</span>
</template>
<template v-else>
<span v-if="isCall" class="live-dot call">老师正在等你</span>
<span v-else-if="isUrgent" class="live-dot">需立即查看</span>
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
<span class="head-sub">{{ subtitle }}</span>
</template>
</div> </div>
<button type="button" class="icon-btn" title="收起" @click.stop="collapse"> <button type="button" class="icon-btn" title="收起" @click.stop="collapse">
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
@@ -627,21 +764,75 @@ onMounted(async () => {
</button> </button>
</header> </header>
<h3 class="title">{{ notification?.title ?? '' }}</h3> <!-- 多条消息:竖向排列,按重要程度排序,默认只显示前三条 -->
<div class="body"> <template v-if="isListMode">
<p class="content-text">{{ notification?.content ?? '' }}</p> <h3 class="title">{{ pendingCount }} 条待处理通知</h3>
<p v-if="callHint" class="call-hint">{{ callHint }}</p> <div class="body list-body">
<p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p> <button
</div> v-for="item in listItems"
:key="item.id"
type="button"
class="list-row"
:title="`打开应用查看:${item.title}`"
@click.stop="openApp"
>
<span class="row-icon" :class="rowKind(item)">
<svg v-if="rowKind(item) === 'homework'" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="rowKind(item) === 'call'" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
/>
</svg>
</span>
<span class="row-text">
<span class="row-title">{{ item.title }}</span>
<span class="row-sub">{{ rowSubText(item) }}</span>
</span>
<span class="row-badge" :class="rowBadgeClass(item)">{{ rowBadgeText(item) }}</span>
</button>
<!-- 还有没显示出来的:能展开就"展开更多",屏幕到任务栏了就只能去应用里看 -->
<div v-if="listHint" class="list-hint">
<button v-if="listHint === 'more'" type="button" class="more-btn" @click.stop="expandList">
展开更多(还有 {{ hiddenCount }} 条)
</button>
<span v-else class="more-app">更多请前往应用内操作</span>
</div>
</div>
</template>
<template v-else>
<h3 class="title">{{ notification?.title ?? '' }}</h3>
<div class="body">
<p class="content-text">{{ notification?.content ?? '' }}</p>
<p v-if="callHint" class="call-hint">{{ callHint }}</p>
<p v-else-if="notification?.subtitle" class="call-hint">{{ notification?.subtitle }}</p>
</div>
</template>
<!-- 一排按钮(多条消息时也是这一排):展开态的操作都收在这里 -->
<footer class="foot"> <footer class="foot">
<span v-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span> <span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span>
<span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
<span v-else class="more muted">来自班级小助手</span> <span v-else class="more muted">来自班级小助手</span>
<span class="actions"> <span class="actions">
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button> <button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
<button type="button" class="ghost-btn" @click.stop="markRead">标为已读</button> <button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead">
标为已读
</button>
<button type="button" class="solid-btn" @click.stop="dismiss"> <button type="button" class="solid-btn" @click.stop="dismiss">
{{ isCall ? '收到' : '知道了' }} {{ isCall && !isListMode ? '收到' : '知道了' }}
</button> </button>
</span> </span>
</footer> </footer>
@@ -743,6 +934,16 @@ body {
fill: var(--wn-bg); fill: var(--wn-bg);
stroke: var(--wn-border); stroke: var(--wn-border);
stroke-width: 1; stroke-width: 1;
/*
投影**始终声明**,形态切换只改参数 —— 不要写成"只在 .expanded 上挂 filter"。
给元素加 / 去 CSS filter 会让 Chromium 新建或销毁它的渲染表面(effect node),
而首帧的合成可能发生在新表面栅格化完成之前,那一帧会被当作空内容画出去;
卡片的底色恰恰就画在这个被过滤的元素上(fill 在本元素),于是**开/合的那一瞬间
底座会闪掉一帧、只剩未过滤的文字层**(用户反馈的"开合时一瞬间的闪动")。
胶囊态用 0 0 0:硬轮廓投影正好压在路径底下,肉眼等同无投影,与 WinIsland
"只有展开态有投影"的观感一致。
*/
filter: drop-shadow(0 var(--card-shadow-y, 0px) var(--card-shadow-blur, 0px) rgba(0, 0, 0, 0.11));
} }
/* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */ /* 内容层裁切到同一形状内;内部两层(胶囊层 / 展开层)交叉淡入 */
@@ -767,9 +968,11 @@ body {
pointer-events: auto; pointer-events: auto;
} }
/* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px) */ /* 展开态投影(WinIsland: rgba(0,0,0,.11)、y+2、σ=3 → CSS 0 2px 3px)。
只改参数、不动 filter 声明本身,理由见上面的 .shape path。 */
.island-card.expanded .shape path { .island-card.expanded .shape path {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.11)); --card-shadow-y: 2px;
--card-shadow-blur: 3px;
} }
/* 状态色:只做"内层微染 + 描边",不外溢(保持桌面干净,也不会有光晕硬边) */ /* 状态色:只做"内层微染 + 描边",不外溢(保持桌面干净,也不会有光晕硬边) */
@@ -809,10 +1012,40 @@ body {
.pill-layer { .pill-layer {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
/* 水平对齐交给 .pill-inner(它固定为胶囊几何,且按停靠方式贴边/居中) */
justify-content: flex-start;
}
/**
* 胶囊内容盒:**固定为胶囊自己的宽高与内边距**,不随卡片形变重排。
*
* 为什么必须固定:卡片在开合时 268 ⇄ 424 变宽变窄,而中心停靠下卡片的左右边都会动。
* 内容若跟着卡片宽度走,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
* (用户反馈的"新消息那行字往右跳一下再缩回"),底部也会被收缩的形状裁掉一块。
* 固定成胶囊几何后:文字在整段动画里位置与省略号都不变,只是随卡片一起淡出/淡入。
*
* 贴哪条边与卡片停靠方式一致(左停靠贴左、右停靠贴右、居中则居中对齐)——
* 这样卡片变宽时内容也不会横移,静止态外观与之前完全一致。
*/
.pill-inner {
display: inline-flex;
flex-direction: row;
align-items: center;
flex: 0 0 auto;
gap: calc(var(--island-font, 13px) * 0.62); gap: calc(var(--island-font, 13px) * 0.62);
width: var(--island-w, 268px);
height: 100%;
padding: 0 calc(var(--island-font, 13px) * 0.78); padding: 0 calc(var(--island-font, 13px) * 0.78);
} }
.island-card[data-anchor='center'] .pill-layer {
justify-content: center;
}
.island-card[data-anchor='right'] .pill-layer {
justify-content: flex-end;
}
.pill-icon { .pill-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -912,6 +1145,166 @@ body {
justify-content: center; justify-content: center;
} }
/*
* 多条通知的列表形态。
*
* 这里的每个尺寸都必须与 @classhelper/shared 的 `ISLAND_LIST_METRICS` 一一对应:
* 主进程按那份度量算出**窗口包围盒**(列表比普通展开卡高,窗口不够高会把底部按钮裁掉),
* 渲染进程按同一份度量排布。改这里就必须改那里,否则会出现"最后一行被切掉"。
* 选择器一律写成 `.expanded-layer.list-mode ...`(比基类的单类选择器更具体),
* 免得被后面定义的 `.title` / `.body` 覆盖掉尺寸——那样高度就对不上算出来的值了。
*/
.expanded-layer.list-mode {
/* 高度已经算准,改 flex-start:居中排版一旦有像素级误差会把上下两端同时切掉 */
justify-content: flex-start;
}
.expanded-layer.list-mode .head {
height: calc(var(--island-font, 13px) * 1.7);
}
.expanded-layer.list-mode .title {
flex: 0 0 auto;
display: block;
height: calc(var(--island-font, 13px) * 1.4);
line-height: 1.4;
-webkit-line-clamp: unset;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.expanded-layer.list-mode .body {
flex: 1 1 auto;
min-height: 0;
gap: calc(var(--island-font, 13px) * 0.4);
justify-content: flex-start;
overflow: hidden;
}
.head-icon.list {
background: color-mix(in srgb, var(--island-accent) 20%, transparent);
color: var(--island-accent);
}
.list-row {
display: flex;
align-items: center;
gap: calc(var(--island-font, 13px) * 0.55);
flex: 0 0 auto;
width: 100%;
height: calc(var(--island-font, 13px) * 2.7);
padding: 0 calc(var(--island-font, 13px) * 0.6);
border: none;
border-radius: calc(var(--island-font, 13px) * 0.5);
background: rgba(255, 255, 255, 0.045);
color: var(--wn-text-1);
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.list-row:hover {
background: var(--wn-surface);
}
.row-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: calc(var(--island-font, 13px) * 1.3);
height: calc(var(--island-font, 13px) * 1.3);
border-radius: calc(var(--island-font, 13px) * 0.36);
background: var(--wn-surface);
color: var(--wn-text-2);
}
.row-icon svg {
width: calc(var(--island-font, 13px) * 0.86);
height: calc(var(--island-font, 13px) * 0.86);
}
.row-icon.call {
color: #ff9f0a;
}
.row-icon.homework {
color: var(--island-accent);
}
.row-text {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
min-width: 0;
flex: 1 1 auto;
}
.row-title {
font-size: calc(var(--island-font, 13px) * 0.9);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-sub {
font-size: calc(var(--island-font, 13px) * 0.68);
line-height: 1.1;
color: var(--wn-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-badge {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
height: calc(var(--island-font, 13px) * 1.25);
padding: 0 calc(var(--island-font, 13px) * 0.5);
border-radius: 999px;
font-size: calc(var(--island-font, 13px) * 0.68);
font-weight: 600;
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.78);
}
/* 列表底部的提示行:能展开时是按钮,屏幕到任务栏了就是一句"去应用里看" */
.list-hint {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
height: calc(var(--island-font, 13px) * 2.1);
}
.more-btn {
border: none;
background: var(--wn-surface);
color: var(--wn-text-1);
height: 100%;
width: 100%;
border-radius: calc(var(--island-font, 13px) * 0.5);
font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.more-btn:hover {
background: var(--wn-surface-hover);
}
.more-app {
font-size: calc(var(--island-font, 13px) * 0.76);
color: var(--wn-text-3);
white-space: nowrap;
}
.head { .head {
display: flex; display: flex;
align-items: center; align-items: center;
+32 -3
View File
@@ -12,6 +12,7 @@ import {
type ClassIslandNotificationChannel, type ClassIslandNotificationChannel,
type IslandAppearance, type IslandAppearance,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { logger } from './logger.js';
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js'; import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */ /** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
@@ -142,8 +143,15 @@ function encryptToken(token: string | null): { token: string | null; tokenEncryp
return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true }; return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true };
} }
} catch { } catch {
// 某些环境下 DPAPI 不可用,降级为明文存储(仅本地开发场景) // 见下方降级分支
} }
// 降级为明文存储(DPAPI 不可用,一般只在本地开发环境出现)。
// 必须留下日志:明文 token 代表该班的全班身份,任何能读用户目录的进程/备份/同步盘都能拿到,
// 静默降级会让"为什么换个环境就得重新登录/凭据是怎么泄露的"无从查起。
logger.warn(
`系统加密存储不可用,登录令牌将以**明文**写入配置(${configFilePath()});` +
'如需加密请确认 Windows 凭据保护(DPAPI)可用',
);
return { token, tokenEncrypted: false }; return { token, tokenEncrypted: false };
} }
@@ -164,7 +172,7 @@ function readPersisted(): PersistedConfig {
return { return {
serverUrl: serverUrl:
typeof parsed.serverUrl === 'string' && parsed.serverUrl typeof parsed.serverUrl === 'string' && parsed.serverUrl
? parsed.serverUrl ? (normalizeServerUrl(parsed.serverUrl) ?? DEFAULT_CONFIG.serverUrl)
: DEFAULT_CONFIG.serverUrl, : DEFAULT_CONFIG.serverUrl,
username: typeof parsed.username === 'string' ? parsed.username : '', username: typeof parsed.username === 'string' ? parsed.username : '',
token: typeof parsed.token === 'string' ? parsed.token : null, token: typeof parsed.token === 'string' ? parsed.token : null,
@@ -185,6 +193,25 @@ function writePersisted(config: PersistedConfig): void {
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8'); fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
} }
/**
* 服务器地址只接受 http/https 绝对地址。
*
* 渲染进程的输入框走 `normalizeServerUrl` 校验过,但 IPC(`saveConfig`)与手工改过的
* config.json 都会绕过它,而该值随后会作为 axios baseURL 与 Socket.IO 地址使用 ——
* 因此必须在**主进程这一侧也收口**,非法值一律丢弃(保留原值)。
*/
function normalizeServerUrl(input: string): string | null {
const trimmed = input.trim().replace(/\/+$/, '');
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return trimmed;
} catch {
return null;
}
}
/** 读取配置(token 已解密),供渲染进程使用 */ /** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig { export function getConfig(): DesktopStoredConfig {
const persisted = readPersisted(); const persisted = readPersisted();
@@ -204,7 +231,9 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
const persisted = readPersisted(); const persisted = readPersisted();
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) { if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
persisted.serverUrl = patch.serverUrl.trim().replace(/\/+$/, ''); const normalized = normalizeServerUrl(patch.serverUrl);
if (normalized) persisted.serverUrl = normalized;
else logger.warn(`忽略非法的服务器地址(只接受 http/https):${patch.serverUrl}`);
} }
if (typeof patch.username === 'string') { if (typeof patch.username === 'string') {
persisted.username = patch.username; persisted.username = patch.username;
+34 -5
View File
@@ -1,4 +1,5 @@
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron'; import { BrowserWindow, app, shell } from 'electron';
import { registerIpcHandlers } from './ipc.js'; import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js'; import { island, registerIslandIpc } from './island.js';
@@ -23,6 +24,30 @@ let mainWindow: BrowserWindow | null = null;
/** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */ /** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */
let isQuitting = false; let isQuitting = false;
/** 生产环境下主窗口加载的本页地址(用于导航白名单的精确比对) */
const prodRendererUrl = pathToFileURL(path.join(currentDir, '../renderer/index.html')).href;
/**
* 是否允许窗口导航到这个地址。
*
* 为什么不能用 `url.startsWith(...)`:
* - 开发态 `url.startsWith(devServerUrl)` 没有主机边界,`http://localhost:5174.evil.com` 也会被放行;
* - 生产态 `url.startsWith('file://')` 等于放行**任意本地页面**。
* 而 preload 是挂在窗口上的(不是挂在 URL 上):一旦窗口被导航到别处,那个页面照样持有
* `window.desktop`(含 `getConfig` 返回的明文 token)。因此这里按"精确同源 / 精确同一文件"比对。
*/
function isAllowedNavigation(url: string): boolean {
try {
const target = new URL(url);
if (devServerUrl) return target.origin === new URL(devServerUrl).origin;
target.hash = '';
target.search = '';
return target.href === prodRendererUrl;
} catch {
return false;
}
}
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
@@ -49,11 +74,10 @@ function createWindow(): BrowserWindow {
return { action: 'deny' }; return { action: 'deny' };
}); });
win.webContents.on('will-navigate', (event, url) => { win.webContents.on('will-navigate', (event, url) => {
const allowed = devServerUrl ? url.startsWith(devServerUrl) : url.startsWith('file://'); if (isAllowedNavigation(url)) return;
if (!allowed) { event.preventDefault();
event.preventDefault(); if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
if (/^https?:\/\//i.test(url)) void shell.openExternal(url); logger.warn(`主窗口拒绝了页面导航:${url}`);
}
}); });
win.on('closed', () => { win.on('closed', () => {
@@ -106,6 +130,11 @@ if (!gotLock) {
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id); mainWindow.webContents.send('island:mark-read', id);
}); });
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) { } catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error); logger.error('灵动岛初始化失败(不影响主功能)', error);
} }
+428 -48
View File
@@ -1,4 +1,5 @@
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, ipcMain, screen } from 'electron'; import { BrowserWindow, ipcMain, screen } from 'electron';
import { import {
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
@@ -7,7 +8,10 @@ import {
ISLAND_SIZE_DELTA, ISLAND_SIZE_DELTA,
ISLAND_SLIVER_WIDTH, ISLAND_SLIVER_WIDTH,
ISLAND_STYLES, ISLAND_STYLES,
PRIORITY_RANK,
islandListLayout,
type IslandAppearance, type IslandAppearance,
type IslandListLayout,
type IslandMode, type IslandMode,
type IslandNotification, type IslandNotification,
type IslandState, type IslandState,
@@ -15,6 +19,26 @@ import {
import { logger } from './logger.js'; import { logger } from './logger.js';
import { getConfig } from './config.js'; import { getConfig } from './config.js';
/** 生产环境的灵动岛页面地址(导航白名单的精确比对用) */
const prodIslandUrl = pathToFileURL(path.join(__dirname, '../renderer/island.html')).href;
/**
* 是否允许灵动岛窗口导航到这个地址。
* 与主窗口同一套理由:preload 挂在窗口上而不是 URL 上,被导航走就等于把 `window.desktop`
* 交给了一个陌生页面;而灵动岛还是置顶窗口,陌生页面会停在屏幕最上层。
*/
function isAllowedIslandNavigation(url: string, devUrl: string | null): boolean {
try {
const target = new URL(url);
if (devUrl) return target.origin === new URL(devUrl).origin;
target.hash = '';
target.search = '';
return target.href === prodIslandUrl;
} catch {
return false;
}
}
/** /**
* 灵动岛(Dynamic Island)主进程控制器。 * 灵动岛(Dynamic Island)主进程控制器。
* *
@@ -102,7 +126,18 @@ export interface IslandClassStatePayload {
week?: number; week?: number;
} }
type IslandActionName = 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app'; type IslandActionName =
| 'expand'
| 'collapse'
/** 多条通知时点"展开更多":把列表铺到屏幕放得下的程度 */
| 'expand-list'
| 'dismiss'
/** 多条通知时点"知道了":整批关闭,但不改通知中心的已读状态 */
| 'dismiss-all'
| 'mark-read'
/** 多条通知时点"标为已读":整批已读并关闭 */
| 'mark-all-read'
| 'open-app';
interface IslandActionPayload { interface IslandActionPayload {
action: IslandActionName; action: IslandActionName;
@@ -127,12 +162,35 @@ class IslandController {
/** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */ /** 是否允许"失焦自动收起"(冒烟可临时关闭,避免焦点抖动干扰断言) */
private blurCollapseEnabled = true; private blurCollapseEnabled = true;
private interactive = false; private interactive = false;
/**
* 触摸模式:触摸屏机器上由渲染进程按 `navigator.maxTouchPoints` 上报(见 `setTouchMode`)。
*
* 为什么触摸屏必须单独一套:触摸屏上"点不动"是**机制性**的,不是偶发 —— 打开命中的两条链路
* (渲染进程的 mousemove 转发、主进程读系统光标位置)都以"鼠标指针移动"为前提,而手指触摸
* 既不产生 mousemove、也不移动系统光标。于是窗口永远停在 `setIgnoreMouseEvents(true)` 的
* 穿透态,手指点下去只落到桌面(用户反馈:希沃白板上触摸灵动岛无法展开)。
*
* 修法只有一条:**窗口在触摸点处必须可命中**。Electron 没有 SetWindowRgn 那种"窗口区域"能力,
* 所以改成在触摸模式下让窗口**贴合岛体**(岛体 + 阴影留白)并始终接收输入 —— 贴身后多出来的
* 只有投影所需的 10px 留白,不再吞掉桌面点击。
*/
private touchMode = false;
/** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */ /** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */
private hitRect: { x: number; y: number; width: number; height: number } | null = null; private hitRect: { x: number; y: number; width: number; height: number } | null = null;
/** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */ /** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */
private hitTimer: ReturnType<typeof setInterval> | null = null; private hitTimer: ReturnType<typeof setInterval> | null = null;
/** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */ /** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */
private cursorOverride: { x: number; y: number } | null = null; private cursorOverride: { x: number; y: number } | null = null;
/**
* 冒烟/诊断用:让窗口**不接收真实鼠标**(真实点击穿透到桌面)。
*
* 为什么需要:自动化验证时长分钟计,而岛上那套命中逻辑是按"真实光标位置"开的 ——
* 若此时用户正在用同一块屏幕(他自己的客户端和冒烟窗口都在屏幕顶部居中、互相叠着),
* 他点到的是**冒烟这个窗口**,冒烟就会收到人的点击(实测:冒烟从未发出的 collapse / mark-read
* 混进了断言,把「点胶囊展开」「点空白处收起」等用例整片弄红)。
* 打开后所有命中请求一律按"穿透"处理;合成 DOM 点击(executeJavaScript)不受影响。
*/
private testInputPassthrough = false;
/** 上次按矩形立即校正命中的时刻(限流用) */ /** 上次按矩形立即校正命中的时刻(限流用) */
private lastHitSyncAt = 0; private lastHitSyncAt = 0;
/** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */ /** 设置页"预览效果"的示例通知 id(预览期间失焦不收起) */
@@ -147,7 +205,10 @@ class IslandController {
private recovering = false; private recovering = false;
/** 最近的窗口重建时刻(限流用) */ /** 最近的窗口重建时刻(限流用) */
private recoverAt: number[] = []; private recoverAt: number[] = [];
/** 上一次计算锚点所依据的显示器 id:用于发现"光标换屏 / 显示器插拔"并重排窗口 */
private anchorDisplayId: number | null = null;
/** 展开卡可用高度上限(CSS px):由工作区/停靠位置算出,随状态下发给渲染进程 */
private maxCardHeight = 1024;
private state: IslandState = { private state: IslandState = {
mode: 'hidden', mode: 'hidden',
active: null, active: null,
@@ -155,6 +216,8 @@ class IslandController {
inClass: false, inClass: false,
currentPeriodEnd: null, currentPeriodEnd: null,
reason: null, reason: null,
listExpanded: false,
maxCardHeight: 1024,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
@@ -164,6 +227,9 @@ class IslandController {
/** 灵动岛点"标为已读"时交给外部(主窗口渲染进程)同步通知中心 */ /** 灵动岛点"标为已读"时交给外部(主窗口渲染进程)同步通知中心 */
private markReadHandler: ((id: string) => void) | null = null; private markReadHandler: ((id: string) => void) | null = null;
/** 灵动岛一次"标为已读"要标记的多条通知(多条列表时的整批已读) */
private markAllReadHandler: ((ids: string[]) => void) | null = null;
setOpenAppHandler(handler: () => void): void { setOpenAppHandler(handler: () => void): void {
this.openAppHandler = handler; this.openAppHandler = handler;
} }
@@ -172,6 +238,10 @@ class IslandController {
this.markReadHandler = handler; this.markReadHandler = handler;
} }
setMarkAllReadHandler(handler: (ids: string[]) => void): void {
this.markAllReadHandler = handler;
}
async init(rendererUrl: string | null): Promise<void> { async init(rendererUrl: string | null): Promise<void> {
this.rendererUrl = rendererUrl; this.rendererUrl = rendererUrl;
// 恢复用户保存的个性化外观(尺寸/位置/透明度/动画…),失败则用默认值 // 恢复用户保存的个性化外观(尺寸/位置/透明度/动画…),失败则用默认值
@@ -181,6 +251,7 @@ class IslandController {
this.appearance = { ...DEFAULT_ISLAND_APPEARANCE }; this.appearance = { ...DEFAULT_ISLAND_APPEARANCE };
} }
recomputeSizes(this.appearance); recomputeSizes(this.appearance);
this.refreshViewport();
if (this.win && !this.win.isDestroyed()) return; if (this.win && !this.win.isDestroyed()) return;
const win = new BrowserWindow({ const win = new BrowserWindow({
@@ -219,6 +290,13 @@ class IslandController {
win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
// 拒绝一切导航与新开窗口(理由见 isAllowedIslandNavigation)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.webContents.on('will-navigate', (event, url) => {
if (isAllowedIslandNavigation(url, this.rendererUrl)) return;
event.preventDefault();
logger.warn(`灵动岛拒绝了页面导航:${url}`);
});
// 注意:这里**不能**调用 `win.setBackgroundMaterial()`(亚克力/mica), // 注意:这里**不能**调用 `win.setBackgroundMaterial()`(亚克力/mica),
// 见 `applyBackgroundMaterial` 移除处的说明。 // 见 `applyBackgroundMaterial` 移除处的说明。
// 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击), // 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击),
@@ -254,7 +332,10 @@ class IslandController {
); );
return; return;
} }
if (sinceActivated < BLUR_GRACE_MS) { // 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
// 上面实测到的 70~520ms 抖动区间,否则手指点开后约 0.5s 会莫名缩回胶囊。
const grace = this.touchMode ? BLUR_GRACE_TOUCH_MS : BLUR_GRACE_MS;
if (sinceActivated < grace) {
logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`); logger.info(`灵动岛:忽略激活后 ${sinceActivated}ms 内的失焦(宽限期内不收起)`);
return; return;
} }
@@ -360,13 +441,10 @@ class IslandController {
): IslandNotification { ): IslandNotification {
if (!existing || existing.id !== incoming.id) return incoming; if (!existing || existing.id !== incoming.id) return incoming;
const kind = existing.kind === 'call' || incoming.kind === 'call' ? 'call' : incoming.kind; const kind = existing.kind === 'call' || incoming.kind === 'call' ? 'call' : incoming.kind;
const rank: Record<IslandNotification['priority'], number> = { const priority =
LOW: 0, (PRIORITY_RANK[incoming.priority] ?? 0) >= (PRIORITY_RANK[existing.priority] ?? 0)
NORMAL: 1, ? incoming.priority
HIGH: 2, : existing.priority;
URGENT: 3,
};
const priority = rank[incoming.priority] >= rank[existing.priority] ? incoming.priority : existing.priority;
return { ...incoming, kind, priority }; return { ...incoming, kind, priority };
} }
@@ -376,6 +454,61 @@ class IslandController {
return this.state.queued.find((item) => item.id === id) ?? null; return this.state.queued.find((item) => item.id === id) ?? null;
} }
/**
* 当前**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」倒序。
*
* 排序口径与共享常量 `PRIORITY_RANK` 一致:紧急 > 重要 > 普通 > 低,同级按时间新的在前。
* 用户在展开卡里看到的就是这个顺序("默认按重要程度排列")。
*/
private pendingNotifications(): IslandNotification[] {
const merged: IslandNotification[] = [];
if (this.state.active) merged.push(this.state.active);
for (const item of this.state.queued) {
if (!merged.some((existing) => existing.id === item.id)) merged.push(item);
}
return merged.sort((a, b) => {
const rank = (PRIORITY_RANK[b.priority] ?? 0) - (PRIORITY_RANK[a.priority] ?? 0);
if (rank !== 0) return rank;
return String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''));
});
}
/**
* 多条通知时的列表布局(几条、要不要"展开更多"、卡片多高)。
* 只有展开态且待处理 ≥ 2 条时才有列表形态,单条通知仍然是原来那张详情卡。
*/
private listLayout(): IslandListLayout | null {
if (this.state.mode !== 'expanded') return null;
const count = this.pendingNotifications().length;
if (count < 2) return null;
return islandListLayout({
count,
fontSize: this.appearance.fontSize,
maxHeight: this.maxCardHeight,
expanded: this.state.listExpanded,
});
}
/**
* 展开卡在当前屏幕 / 停靠位置下的可用高度上限:
* 从"岛被锚定的那条边"量到屏幕工作区的另一端(也就是任务栏上方)。
*
* 用锚点 `vValue` 而不是窗口矩形来算:锚点与窗口尺寸无关,因此**列表长高后再算还是同一个数**
* (用窗口矩形会自引用:窗口高度取决于列表高度,列表高度又取决于可用高度)。
*/
private refreshViewport(): void {
const area = this.targetDisplay().workArea;
const anchor = this.resolveAnchor();
const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN);
const available =
anchor.vMode === 'bottom'
? anchor.vValue - (area.y + marginY)
: area.y + area.height - marginY - anchor.vValue;
// 下限兜底:屏幕再小也不能用 0 去算布局(否则连一行都排不出来)
this.maxCardHeight = Math.max(MIN_CARD_HEIGHT, Math.floor(available));
this.state.maxCardHeight = this.maxCardHeight;
}
/** /**
* 收到一条消息。 * 收到一条消息。
* - 紧急(含紧急叫人):立刻展开(无视上课时段) * - 紧急(含紧急叫人):立刻展开(无视上课时段)
@@ -384,6 +517,7 @@ class IslandController {
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开 * - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
*/ */
pushNotification(raw: IslandNotification, context: IslandPushContext = {}): void { pushNotification(raw: IslandNotification, context: IslandPushContext = {}): void {
this.state.listExpanded = false;
// 同一 id 的第二条链路(通知/叫人双播):合并而不是覆盖,保住"叫人/紧急"语义 // 同一 id 的第二条链路(通知/叫人双播):合并而不是覆盖,保住"叫人/紧急"语义
const notification = this.mergeNotification(this.findKnownNotification(raw.id), raw); const notification = this.mergeNotification(this.findKnownNotification(raw.id), raw);
const inClass = context.inClass ?? this.state.inClass; const inClass = context.inClass ?? this.state.inClass;
@@ -398,7 +532,7 @@ class IslandController {
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id); this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
this.state.active = notification; this.state.active = notification;
this.state.updatedAt = Date.now(); this.state.updatedAt = Date.now();
this.setState({ mode: 'expanded' }); this.setState({ mode: 'expanded', listExpanded: false });
this.previewTimer = setTimeout(() => { this.previewTimer = setTimeout(() => {
this.previewTimer = null; this.previewTimer = null;
this.endPreview(notification.id); this.endPreview(notification.id);
@@ -561,7 +695,8 @@ class IslandController {
// 记下这条 id,syncWindow 的上课守卫对它是放行的(否则展开后会被立刻隐藏, // 记下这条 id,syncWindow 的上课守卫对它是放行的(否则展开后会被立刻隐藏,
// 表现为"点不开新作业"——用户反馈的联合通知场景)。 // 表现为"点不开新作业"——用户反馈的联合通知场景)。
this.explicitShowId = this.state.inClass ? active.id : null; this.explicitShowId = this.state.inClass ? active.id : null;
this.setState({ mode: 'expanded' }); // 每次重新点开都从"只显示前三条"开始(上文的 setState 只在离开 expanded 时复位)
this.setState({ mode: 'expanded', listExpanded: false });
this.scheduleCollapse( this.scheduleCollapse(
this.isImmediate(active) this.isImmediate(active)
? active.kind === 'call' ? active.kind === 'call'
@@ -572,6 +707,18 @@ class IslandController {
} }
break; break;
} }
case 'expand-list': {
// 多条通知时点"展开更多":把列表铺到屏幕放得下的程度(窗口跟着长高,见 setState)
if (this.state.mode === 'hidden' || this.state.listExpanded) break;
const layout = this.listLayout();
if (!layout) break;
logger.info(
`灵动岛:展开更多(共 ${this.pendingNotifications().length} 条,显示 ${layout.rows} 条,提示=${layout.hint ?? '无'})`,
);
this.setState({ mode: 'expanded', listExpanded: true });
this.scheduleCollapse(TIMEOUTS.expanded);
break;
}
case 'collapse': case 'collapse':
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊) // 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
// 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开) // 上课时段:普通通知彻底隐藏;**叫人 / 紧急消息**回缩为胶囊(保持可见,允许再次展开)
@@ -589,6 +736,11 @@ class IslandController {
case 'dismiss': case 'dismiss':
this.dismissActive(); this.dismissActive();
break; break;
case 'dismiss-all':
// 多条通知时点"知道了":整批关闭(通知中心仍是未读,学生回应用里再看)
logger.info(`灵动岛:整批知道了(${this.pendingNotifications().length} 条)`);
this.clearPendingForClose();
break;
case 'mark-read': { case 'mark-read': {
const id = payload.id; const id = payload.id;
const queue = this.state.queued.filter((item) => item.id !== id); const queue = this.state.queued.filter((item) => item.id !== id);
@@ -604,6 +756,18 @@ class IslandController {
} }
break; break;
} }
case 'mark-all-read': {
// 多条通知时点"标为已读":**默认把这一批全部标记已读**再关闭。
// 作业卡的 id 是本地合成的(`homework-<id>`,库里没有这条通知),交给通知中心只会报"通知不存在",
// 因此这里先按 kind 过滤掉,单条模式的 canMarkRead 守卫同理。
const ids = this.pendingNotifications()
.filter((item) => item.kind !== 'homework')
.map((item) => item.id);
logger.info(`灵动岛:整批标为已读(${ids.length} 条)`);
if (ids.length > 0) this.markAllReadHandler?.(ids);
this.clearPendingForClose();
break;
}
case 'open-app': case 'open-app':
this.openAppHandler?.(); this.openAppHandler?.();
break; break;
@@ -676,6 +840,19 @@ class IslandController {
this.hide(); this.hide();
} }
/**
* 整批清空并关闭("知道了 / 标为已读"的多条场景)。
* 状态同步落地(渲染进程随之收起卡片,窗口由 `hide()` 淡出)。
*/
private clearPendingForClose(): void {
this.clearTimers();
this.state.active = null;
this.state.queued = [];
this.state.listExpanded = false;
this.hide();
}
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void { private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
this.clearTimers(); this.clearTimers();
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的 // 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
@@ -756,9 +933,7 @@ class IslandController {
*/ */
private resolveAnchor(): IslandAnchor { private resolveAnchor(): IslandAnchor {
// 多显示器教室电脑:打开"跟随鼠标屏幕"后,岛出现在鼠标所在的那块屏(讲台切换投影时不用改设置) // 多显示器教室电脑:打开"跟随鼠标屏幕"后,岛出现在鼠标所在的那块屏(讲台切换投影时不用改设置)
const display = this.appearance.followCursorDisplay const display = this.targetDisplay();
? screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
: screen.getPrimaryDisplay();
const area = display.workArea; const area = display.workArea;
const marginX = Math.round(this.appearance.marginX ?? ISLAND_MARGIN); const marginX = Math.round(this.appearance.marginX ?? ISLAND_MARGIN);
const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN); const marginY = Math.round(this.appearance.marginY ?? ISLAND_MARGIN);
@@ -782,9 +957,38 @@ class IslandController {
return { hMode, hValue, vMode, vValue }; return { hMode, hValue, vMode, vValue };
} }
/** 当前应该把岛放在哪块屏上:跟随鼠标时用光标所在屏,否则用主屏 */
private targetDisplay(): Electron.Display {
return this.appearance.followCursorDisplay
? screen.getDisplayNearestPoint(this.cursorOverride ?? screen.getCursorScreenPoint())
: screen.getPrimaryDisplay();
}
/**
* 锚点所在屏幕变化时重排窗口("跟随鼠标屏幕"、主屏切换、显示器插拔都会走到这里)。
*
* 原先锚点只在 `setAppearance()` 里算一次,于是"跟随鼠标屏幕"实际上**只在改设置的那一瞬间**
* 生效:讲台从投影切回来之后,岛还留在原来那块屏上,直到用户再动一次外观设置。
* 这里挂在已有的 60ms 命中轮询上(不新增定时器),并且只在目标屏幕真的变了、且窗口可见时才动窗口。
*/
private syncAnchorDisplay(): void {
if (!this.win || this.win.isDestroyed()) return;
const displayId = this.targetDisplay().id;
if (this.anchorDisplayId === displayId) return;
this.anchorDisplayId = displayId;
// 换屏了:可用高度(工作区)也跟着换,列表"能放几行"要重算
this.refreshViewport();
if (!this.win.isVisible()) return;
this.relayout();
}
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */ /** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void { private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
this.state = { ...this.state, ...patch, updatedAt: Date.now() }; this.state = { ...this.state, ...patch, updatedAt: Date.now() };
// 离开展开态就把"展开更多"复位:下次再点开时默认回到"只显示前三条"
if (this.state.mode !== 'expanded') this.state.listExpanded = false;
// 列表形态比普通展开卡高:窗口要**先**长高,卡片才开始形变,否则底部会被窗口裁掉
if (!options.skipWindow) this.relayout();
this.emit(); this.emit();
if (!options.skipWindow) this.syncWindow(); if (!options.skipWindow) this.syncWindow();
} }
@@ -825,6 +1029,11 @@ class IslandController {
// 展开态才允许获得焦点,这样点击屏幕其他位置会 blur → 自动收起 // 展开态才允许获得焦点,这样点击屏幕其他位置会 blur → 自动收起
this.applyFocusable(); this.applyFocusable();
// 窗口不可见时先把包围盒对齐到当前形态:列表形态留下的"高窗口"在这里收回去。
// 必须挑"不可见"的时刻做 —— 可见时改大小会把正在形变的卡片裁掉一截。
if (!this.win.isVisible()) this.applyWindowLayout();
// 触摸模式:形态变了,窗口要立刻跟着变大(缩小交给岛体矩形上报后判定,避免裁掉形变中的卡片)
this.syncTouchLayout();
if (this.state.mode === 'hidden') { if (this.state.mode === 'hidden') {
// 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱; // 空闲"细缝"(参考 WinIsland):打开后空闲不再完全隐藏,而是留一条很窄的圆角柱;
@@ -857,9 +1066,69 @@ class IslandController {
this.syncHitFromCursor(); this.syncHitFromCursor();
} }
/**
* 窗口包围盒:
* - 普通(鼠标)模式:固定取"最大形态 + 2×阴影留白",**开合过程中一帧都不动**(照搬 WinIsland);
* 唯一的例外是**多条通知的列表**:它比任何普通形态都高,窗口必须当场长高,否则卡片底部
* (那排按钮)会被窗口裁掉。窗口是在卡片开始形变**之前**改的,用户看不到这一步。
* - 触摸模式:贴合当前形态(岛体 + 2×阴影留白),否则大包围盒的透明区会吞掉桌面的触摸。
*/
private windowBox(): { width: number; height: number } {
const size = this.formSize();
if (!this.touchMode) {
return {
width: Math.round(Math.max(BBOX_SIZE.width, size.width + ISLAND_SHADOW_PAD * 2)),
height: Math.round(Math.max(BBOX_SIZE.height, size.height + ISLAND_SHADOW_PAD * 2)),
};
}
return {
width: Math.round(size.width + ISLAND_SHADOW_PAD * 2),
height: Math.round(size.height + ISLAND_SHADOW_PAD * 2),
};
}
/**
* 触摸模式下重排窗口(贴合岛体):
* **变大立刻生效**(否则形变中的卡片会被窗口裁掉一半);**变小要等岛真的收小**——
* 判据是渲染进程上报的真实岛体矩形已经装得下,见 `cardFitsFormSize`。
*/
private syncTouchLayout(): void {
if (!this.touchMode || !this.win || this.win.isDestroyed()) return;
const target = this.windowBox();
const current = this.win.getBounds();
const shrinking = target.width < current.width || target.height < current.height;
/**
* **窗口可见期间只增不减**(触摸模式)。
*
* 为什么:触摸模式下窗口是"贴合岛体"的(手指要能点到岛),所以形态变化时窗口本该跟着变
* —— 但**收小会让出一块区域**,Windows/DWM 在那一瞬间可能还留着上一帧的内容,
* 用户看到的就是"一缩回/一展开就闪一下、像旧画面残留在那里"(反复反馈、逐帧抓过的那一类)。
* 变大属于"只增不减",不会让出任何区域;所以:
* - 需要**变大**就立刻变大(否则形变中的卡片会被窗口裁掉);
* - 需要**变小**一律等窗口隐藏之后再收(`syncWindow` 里"不可见时对齐包围盒",
* 不可见时改尺寸不会有残影),这也顺带修掉"隐藏态改停靠/边距不生效"那条。
*/
if (shrinking && this.win.isVisible()) return;
this.applyWindowLayout();
}
/** 渲染进程上报的岛体是否已经收进"当前形态"的尺寸里(触摸模式下判断能否缩小窗口) */
private cardFitsFormSize(): boolean {
const rect = this.hitRect;
if (!rect) return false;
const size = this.formSize();
return rect.width <= size.width + 1 && rect.height <= size.height + 1;
}
/** 外观 / 锚点变化后的窗口重排:普通模式按固定包围盒重排,触摸模式走贴合逻辑 */
private relayout(): void {
if (this.touchMode) this.syncTouchLayout();
else this.applyWindowLayout();
}
/** /**
* 按当前外观把窗口放到锚点上(**只在尺寸/位置设置变化时调用**,开合时不调用)。 * 按当前外观把窗口放到锚点上(**只在尺寸/位置设置变化时调用**,开合时不调用)。
* 窗口尺寸 = 最大形态 + 2×阴影留白,因此任何形态的岛都能完整容纳。 * 尺寸见 `windowBox`:普通模式恒为"最大形态 + 阴影留白",触摸模式贴合当前形态。
*/ */
private applyWindowLayout(): void { private applyWindowLayout(): void {
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
@@ -876,10 +1145,11 @@ class IslandController {
this.win.setBounds(next); this.win.setBounds(next);
} }
/** 固定包围盒窗口的矩形:以锚点定位,尺寸恒为"最大形态 + 阴影留白" */ /** 窗口矩形:以锚点定位(尺寸由 `windowBox` 决定,触摸模式随形态变化) */
private windowBounds(): Bounds { private windowBounds(): Bounds {
const width = Math.round(BBOX_SIZE.width); const box = this.windowBox();
const height = Math.round(BBOX_SIZE.height); const width = box.width;
const height = box.height;
const anchor = this.resolveAnchor(); const anchor = this.resolveAnchor();
// 窗口比岛大 ISLAND_SHADOW_PAD(给投影留白),因此窗口要再偏移一个 pad, // 窗口比岛大 ISLAND_SHADOW_PAD(给投影留白),因此窗口要再偏移一个 pad,
@@ -1005,8 +1275,16 @@ class IslandController {
* 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。 * 窗口只在尺寸/位置变化时**重排一次**(`applyWindowLayout`),开合过程不碰窗口。
*/ */
setAppearance(patch: Partial<IslandAppearance>): void { setAppearance(patch: Partial<IslandAppearance>): void {
this.appearance = normalizeAppearance({ ...this.appearance, ...patch }); // 先丢掉显式 undefined 的键:设置页/冒烟脚本传的都是**部分补丁**,
// 一个残缺字段不该把对应外观重置成默认值(NaN 的兜底在 normalizeAppearance 里)
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(patch ?? {})) {
if (value !== undefined) clean[key] = value;
}
this.appearance = normalizeAppearance({ ...this.appearance, ...clean } as IslandAppearance);
recomputeSizes(this.appearance); recomputeSizes(this.appearance);
// 字号/边距会影响"列表能放几行",可用高度也随停靠位置变化 —— 重算一次并随状态下发
this.refreshViewport();
if (this.win && !this.win.isDestroyed()) { if (this.win && !this.win.isDestroyed()) {
this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver');
@@ -1017,7 +1295,7 @@ class IslandController {
} }
this.emitAppearance(); this.emitAppearance();
// 包围盒/锚点可能变化:窗口重排一次(**不是逐帧**,开合过程中绝不会再碰窗口) // 包围盒/锚点可能变化:窗口重排一次(**不是逐帧**,开合过程中绝不会再碰窗口)
this.applyWindowLayout(); this.relayout();
// 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作) // 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作)
if (this.state.mode !== 'hidden') this.syncWindow(); if (this.state.mode !== 'hidden') this.syncWindow();
logger.info( logger.info(
@@ -1039,10 +1317,17 @@ class IslandController {
* "点开了再收起,就再也点不开了"。 * "点开了再收起,就再也点不开了"。
*/ */
setInteractive(interactive: boolean, source = '未知'): void { setInteractive(interactive: boolean, source = '未知'): void {
if (this.interactive === interactive) return; // 触摸模式:窗口已贴合岛体,必须**始终**接收输入。触摸不会产生光标移动,按光标判定命中的
this.interactive = interactive; // 那套逻辑在触摸屏上永远不会打开,一旦让它把命中关掉,手指就再也点不到("点不动")。
// 测试输入穿透:真实鼠标一律不接收(理由见 testInputPassthrough 字段);
// 但**注入光标**(冒烟用 setHitTestCursor 模拟指针在岛上/岛外)照常生效,
// 否则「光标轮询校正命中」「岛外穿透」这类断言全会被自己的开关弄红。
const passthrough = this.testInputPassthrough && this.cursorOverride === null;
const next = passthrough ? false : this.touchMode ? true : interactive;
if (this.interactive === next) return;
this.interactive = next;
logger.info( logger.info(
`灵动岛:鼠标命中=${interactive}(来自${source})mode=${this.state.mode} ` + `灵动岛:鼠标命中=${next}(请求=${interactive},来自${source})mode=${this.state.mode} ` +
`窗口可见=${this.win?.isVisible() ?? false} 岛体矩形=${ `窗口可见=${this.win?.isVisible() ?? false} 岛体矩形=${
this.hitRect ? `${Math.round(this.hitRect.width)}x${Math.round(this.hitRect.height)}` : '无' this.hitRect ? `${Math.round(this.hitRect.width)}x${Math.round(this.hitRect.height)}` : '无'
}`, }`,
@@ -1050,7 +1335,9 @@ class IslandController {
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
// forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程, // forward: true 让窗口在"忽略鼠标"时仍把 mousemove 转发给渲染进程,
// 这样渲染进程才能发现指针进入岛体并重新打开命中。 // 这样渲染进程才能发现指针进入岛体并重新打开命中。
this.win.setIgnoreMouseEvents(!interactive, { forward: true }); // 注意传的是 next 而不是入参:触摸模式下入参可能是 false(按光标判定),
// 直接用它会把窗口又打回穿透态 —— 那正是"手指点不开"的根因。
this.win.setIgnoreMouseEvents(!next, { forward: true });
} }
/** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */ /** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */
@@ -1062,6 +1349,9 @@ class IslandController {
this.lastHitSyncAt = now; this.lastHitSyncAt = now;
this.syncHitFromCursor(); this.syncHitFromCursor();
} }
// 触摸模式:窗口要一直贴合岛体,而"收小"必须等岛真的收小之后再做(否则形变中的卡片被窗口裁掉)。
// 渲染进程每 ≥50ms 就会上报一次真实几何,正好拿来当这个判据。
this.syncTouchLayout();
} }
/** 当前岛体矩形(供冒烟验证) */ /** 当前岛体矩形(供冒烟验证) */
@@ -1069,6 +1359,42 @@ class IslandController {
return this.hitRect ? { ...this.hitRect } : null; return this.hitRect ? { ...this.hitRect } : null;
} }
/**
* 设置触摸模式(触摸屏机器由渲染进程上报,冒烟也可显式开关)。
*
* 开启后:窗口贴合岛体(见 `windowBox`)且不再穿透(见 `setInteractive`)——
* 这是让"手指按在岛上"能被 Windows 命中到本窗口的唯一办法(详见 `touchMode` 字段的注释)。
* 关闭后回到"固定大包围盒 + 按光标命中"的原行为,因此非触摸屏机器的表现完全不变。
*/
setTouchMode(enabled: boolean): void {
if (this.touchMode === enabled) return;
this.touchMode = enabled;
logger.info(
`灵动岛:触摸模式=${enabled}(窗口${enabled ? '贴合岛体并始终接收输入' : '回到固定包围盒穿透'})`,
);
// 直接重排到目标包围盒:开启时贴合岛体,关闭时回到"最大形态 + 阴影留白"的固定大包围盒
this.applyWindowLayout();
if (enabled) this.setInteractive(true, '触摸模式');
else this.syncHitFromCursor();
}
/** 是否处于触摸模式(供冒烟验证) */
getTouchMode(): boolean {
return this.touchMode;
}
/**
* 开关"测试输入穿透"(冒烟用,见 `testInputPassthrough`):
* 打开后真实鼠标点击穿透到桌面,不会污染自动化断言;关闭后立刻按真实光标位置恢复命中。
*/
setTestInputPassthrough(enabled: boolean): void {
if (this.testInputPassthrough === enabled) return;
this.testInputPassthrough = enabled;
logger.info(`灵动岛:测试输入穿透=${enabled}(供自动化验证不被并行人工操作打扰)`);
if (enabled) this.setInteractive(false, '测试输入穿透');
else this.syncHitFromCursor();
}
/** /**
* 诊断 / 冒烟专用:覆盖"光标位置"(屏幕坐标),让命中兜底轮询按指定坐标校正。 * 诊断 / 冒烟专用:覆盖"光标位置"(屏幕坐标),让命中兜底轮询按指定坐标校正。
* *
@@ -1099,23 +1425,39 @@ class IslandController {
return this.hitRect ?? this.expectedHitRect(); return this.hitRect ?? this.expectedHitRect();
} }
/** 按当前形态/停靠位置算出岛体目标矩形(窗口内 CSS px),仅作兜底 */ /** 当前形态下**岛本身**的尺寸(不是窗口尺寸;响应进程的 `.island-card` 宽高) */
private expectedHitRect(): { x: number; y: number; width: number; height: number } { private formSize(): { width: number; height: number } {
let size = SIZES.pill; // 多条通知的列表形态:高度由统一布局算法算出(渲染进程算的是同一个数)
const layout = this.listLayout();
if (layout) return { width: SIZES.expanded.width, height: layout.height };
if (this.state.mode === 'expanded') { if (this.state.mode === 'expanded') {
size = return this.state.active?.kind === 'call'
this.state.active?.kind === 'call' ? CALL_SIZE
? CALL_SIZE : this.state.active?.priority === 'URGENT'
: this.state.active?.priority === 'URGENT' ? URGENT_SIZE
? URGENT_SIZE : SIZES.expanded;
: SIZES.expanded;
} else if (this.state.mode === 'hidden' && this.appearance.idleSliver) {
size = SLIVER_SIZE;
} }
if (this.state.mode === 'hidden' && this.appearance.idleSliver) return SLIVER_SIZE;
return SIZES.pill;
}
/**
* 按当前形态/停靠位置算出岛体目标矩形(窗口内 CSS px),仅作兜底。
*
* 定位规则必须与渲染进程的 CSS 完全一致(`islandStyle`):左/右停靠贴边留 pad,
* 居中则水平居中;上/下停靠同理。这里用**窗口实际尺寸**而不是 BBOX_SIZE 计算 ——
* 触摸模式下窗口会贴合岛体,用固定的 BBOX_SIZE 会算出错误偏移。
*/
private expectedHitRect(): { x: number; y: number; width: number; height: number } {
const size = this.formSize();
const pad = ISLAND_SHADOW_PAD; const pad = ISLAND_SHADOW_PAD;
const position = this.appearance.position; const position = this.appearance.position;
const width = Math.round(BBOX_SIZE.width); const bounds =
const height = Math.round(BBOX_SIZE.height); this.win && !this.win.isDestroyed()
? this.win.getBounds()
: { width: Math.round(BBOX_SIZE.width), height: Math.round(BBOX_SIZE.height) };
const width = bounds.width;
const height = bounds.height;
const x = position.endsWith('left') const x = position.endsWith('left')
? pad ? pad
: position.endsWith('right') : position.endsWith('right')
@@ -1171,6 +1513,9 @@ class IslandController {
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**, // 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。 // 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
this.hitTimer = setInterval(() => { this.hitTimer = setInterval(() => {
// 先按目标屏幕校正窗口位置(换屏/插拔显示器),再算命中与心跳:
// 顺序不能反,否则命中判定会用上一块屏的窗口坐标
this.syncAnchorDisplay();
this.syncHitFromCursor(); this.syncHitFromCursor();
this.checkRendererAlive(); this.checkRendererAlive();
}, 60); }, 60);
@@ -1325,24 +1670,36 @@ export function getSliverSize(): { width: number; height: number } {
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */ /** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
export function normalizeAppearance(input: IslandAppearance): IslandAppearance { export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
const clamp = (value: number, min: number, max: number): number => /**
Math.min(max, Math.max(min, Math.round(value * 100) / 100)); * 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
*
* 注意 `Math.max(min, NaN)` 仍是 NaN、`Math.min(max, NaN)` 也是 NaN —— 也就是说"只夹紧"
* 并不能防住 NaN。而 `setAppearance()` 的入参来自 IPC(`island:set-appearance` 只判了 `if (patch)`),
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
*/
const clamp = (value: number, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
};
const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
return { return {
height: clamp(input.height, 36, 72), height: clamp(input.height, 36, 72, fallbackOf.height),
width: clamp(input.width, 220, 420), width: clamp(input.width, 220, 420, fallbackOf.width),
radius: clamp(input.radius, 8, 32), radius: clamp(input.radius, 8, 32, fallbackOf.radius),
opacity: clamp(input.opacity, 0.4, 1), opacity: clamp(input.opacity, 0.4, 1, fallbackOf.opacity),
accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent, accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent,
fontSize: clamp(input.fontSize, 11, 20), fontSize: clamp(input.fontSize, 11, 20, fallbackOf.fontSize),
animations: input.animations !== false, animations: input.animations !== false,
speed: clamp(input.speed, 0.5, 2), speed: clamp(input.speed, 0.5, 2, fallbackOf.speed),
position: ISLAND_POSITIONS.includes(input.position) ? input.position : 'top-center', position: ISLAND_POSITIONS.includes(input.position) ? input.position : 'top-center',
alwaysOnTop: input.alwaysOnTop !== false, alwaysOnTop: input.alwaysOnTop !== false,
style: ISLAND_STYLES.includes(input.style) ? input.style : 'black', style: ISLAND_STYLES.includes(input.style) ? input.style : 'black',
idleSliver: input.idleSliver === true, idleSliver: input.idleSliver === true,
// 边距:0 表示贴着屏幕边缘,上限给到 200/160 是为了"多显示器 + 任务栏在侧面"这类布局 // 边距:0 表示贴着屏幕边缘,上限给到 200/160 是为了"多显示器 + 任务栏在侧面"这类布局
marginX: clamp(input.marginX ?? DEFAULT_ISLAND_APPEARANCE.marginX, 0, 200), marginX: clamp(input.marginX ?? DEFAULT_ISLAND_APPEARANCE.marginX, 0, 200, fallbackOf.marginX),
marginY: clamp(input.marginY ?? DEFAULT_ISLAND_APPEARANCE.marginY, 0, 160), marginY: clamp(input.marginY ?? DEFAULT_ISLAND_APPEARANCE.marginY, 0, 160, fallbackOf.marginY),
followCursorDisplay: input.followCursorDisplay === true, followCursorDisplay: input.followCursorDisplay === true,
}; };
} }
@@ -1370,9 +1727,26 @@ const ISLAND_MARGIN = 8;
*/ */
const BLUR_GRACE_MS = 500; const BLUR_GRACE_MS = 500;
/**
* 触摸屏上的失焦宽限期(触摸模式专用,见 `touchMode`)。
*
* 触摸没有光标,`isCursorOnIsland()` 这条"指针还在岛上就判定为激活抖动"的兜底恒为 false,
* 只剩宽限期这一层防线。而实测的假失焦出现在 focus 之后 70~520ms,正好跨过 500ms 的边界,
* 不放开的话手指点开后会偶发地立刻缩回胶囊。取 1200ms 给它 2 倍余量;
* 真正的"点屏幕别处收起"仍会在 1.2s 后生效。
*/
const BLUR_GRACE_TOUCH_MS = 1200;
/** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */ /** 设置页"预览效果"的示例岛停留时长(到点自动收尾,避免一直挂在桌面上) */
const PREVIEW_DURATION_MS = 30_000; const PREVIEW_DURATION_MS = 30_000;
/**
* 展开卡可用高度的下限(CSS px):屏幕再矮也要留出这么多,
* 否则"列表能放几行"会算出负数。真正的工作区空间由 `refreshViewport` 算,这里只是防呆。
*/
const MIN_CARD_HEIGHT = 120;
/** /**
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。 * 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上** * 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
@@ -1446,6 +1820,12 @@ export function registerIslandIpc(): void {
}, },
); );
// 触摸屏上报(渲染进程按 navigator.maxTouchPoints 判定)→ 窗口改为贴合岛体并始终接收输入。
// 触摸屏上按光标判定命中的那套永远打不开命中,手指点不到岛(详见 IslandController.touchMode)。
ipcMain.on('island:set-touch-mode', (_event, enabled: boolean) => {
island.setTouchMode(enabled === true);
});
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => { ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
if (!payload?.action) return; if (!payload?.action) return;
island.handleAction(payload); island.handleAction(payload);
File diff suppressed because it is too large. Load diff
@@ -19,6 +19,17 @@ import type { IslandNotification } from '@classhelper/shared';
*/ */
const bridge: DesktopBridge = { const bridge: DesktopBridge = {
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1', smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
// 冒烟凭据:只在冒烟模式下透传,供渲染进程的冒烟钩子构造测试数据用。
// **不走代码字面量**——本文件与渲染产物都会进 app.asar,写死账号等于随安装包把教师口令发出去。
smokeCredentials:
process.env.ELECTRON_SMOKE_TEST === '1'
? {
username: process.env.ELECTRON_SMOKE_USER ?? '',
password: process.env.ELECTRON_SMOKE_PASSWORD ?? '',
classCode: process.env.ELECTRON_SMOKE_CLASS_CODE ?? '',
classPassword: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
}
: undefined,
getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig), getConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.getConfig),
saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> => saveConfig: (patch: Partial<DesktopStoredConfig>): Promise<DesktopStoredConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch), ipcRenderer.invoke(IPC_CHANNELS.saveConfig, patch),
@@ -41,6 +52,10 @@ const bridge: DesktopBridge = {
onIslandMarkRead: (handler: (id: string) => void): void => { onIslandMarkRead: (handler: (id: string) => void): void => {
ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id)); ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id));
}, },
/** 灵动岛点了"标为已读"(多条通知的整批):一次同步这一批 */
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
},
}; };
contextBridge.exposeInMainWorld('desktop', bridge); contextBridge.exposeInMainWorld('desktop', bridge);
+21 -1
View File
@@ -7,7 +7,18 @@ import type { IslandAppearance, IslandState } from '@classhelper/shared';
*/ */
export interface IslandBridge { export interface IslandBridge {
onState(handler: (state: IslandState) => void): void; onState(handler: (state: IslandState) => void): void;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; sendAction(
action:
| 'expand'
| 'expand-list'
| 'collapse'
| 'dismiss'
| 'dismiss-all'
| 'mark-read'
| 'mark-all-read'
| 'open-app',
id?: string,
): void;
getState(): Promise<IslandState>; getState(): Promise<IslandState>;
/** 订阅个性化外观(设置页改动后实时生效) */ /** 订阅个性化外观(设置页改动后实时生效) */
onAppearance(handler: (appearance: IslandAppearance) => void): void; onAppearance(handler: (appearance: IslandAppearance) => void): void;
@@ -16,6 +27,12 @@ export interface IslandBridge {
setInteractive(interactive: boolean): void; setInteractive(interactive: boolean): void;
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */ /** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void; setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
/**
* 上报"本机是触摸屏":主进程据此把窗口改为贴合岛体并始终接收输入。
* 触摸屏上按光标判定命中的那套永远打不开(触摸不产生 mousemove、也不动系统光标),
* 不上报的话手指点不到岛(希沃白板等触摸设备)。
*/
setTouchMode(enabled: boolean): void;
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */ /** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
alive(): void; alive(): void;
} }
@@ -38,6 +55,9 @@ const bridge: IslandBridge = {
setHitRect: (rect) => { setHitRect: (rect) => {
ipcRenderer.send('island:set-hit-rect', rect); ipcRenderer.send('island:set-hit-rect', rect);
}, },
setTouchMode: (enabled) => {
ipcRenderer.send('island:set-touch-mode', enabled);
},
alive: () => { alive: () => {
ipcRenderer.send('island:alive'); ipcRenderer.send('island:alive');
}, },
+8 -2
View File
@@ -1,7 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { startIslandBridge, stopIslandBridge, subscribeIslandMarkRead } from './island/bridge.js'; import {
startIslandBridge,
stopIslandBridge,
subscribeIslandMarkAllRead,
subscribeIslandMarkRead,
} from './island/bridge.js';
import { useAppStore } from './stores/app.js'; import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js'; import { useAuthStore } from './stores/auth.js';
import { useRealtimeStore } from './stores/realtime.js'; import { useRealtimeStore } from './stores/realtime.js';
@@ -41,8 +46,9 @@ onMounted(async () => {
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出) // 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
if (auth.token) startIslandBridge(); if (auth.token) startIslandBridge();
// 灵动岛"标为已读" → 同步通知中心(未读红点) // 灵动岛"标为已读" → 同步通知中心(未读红点);多条通知时是整批已读
subscribeIslandMarkRead(); subscribeIslandMarkRead();
subscribeIslandMarkAllRead();
if (auth.isAuthenticated) await router.replace('/schedule'); if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login'); else await router.replace('/login');
@@ -21,13 +21,22 @@ import { api } from './http.js';
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */ /** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
export const authApi = { export const authApi = {
/** 个人账号登录(教师/管理员/个人学生;客户端界面已不再展示,保留以兼容旧流程与排障) */ /** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload), login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */ /** 班级账号登录(学生端主入口):班级码 + 班级密码 */
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> => classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
api.post('/auth/class-login', payload), api.post('/auth/class-login', payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}), logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me), me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
/**
* 修改密码(设置页「账号信息」里的入口)。
* 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 ——
* 因此这台机器无论以哪种身份登录,改密入口都走这一条。
*/
changePassword: (payload: {
currentPassword: string;
newPassword: string;
}): Promise<{ changed: boolean }> => api.patch(API_PATHS.auth.changePassword, payload),
}; };
export const classApi = { export const classApi = {
@@ -110,8 +119,10 @@ export const notificationApi = {
list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> => list: (params?: { classId?: string; unreadOnly?: boolean }): Promise<NotificationDto[]> =>
api.get(API_PATHS.notifications, params), api.get(API_PATHS.notifications, params),
unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`), unreadCount: (): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`),
// id 来自服务端或灵动岛(作业卡是本地合成的 `homework-…`),一律 encodeURIComponent:
// 否则含 `/` 或 `..` 的 id 会改变实际请求的路径
markRead: (id: string): Promise<{ notificationId: string; readAt: string }> => markRead: (id: string): Promise<{ notificationId: string; readAt: string }> =>
api.post(`${API_PATHS.notifications}/${id}/read`, {}), api.post(`${API_PATHS.notifications}/${encodeURIComponent(id)}/read`, {}),
markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}), markAllRead: (): Promise<{ marked: number }> => api.post(`${API_PATHS.notifications}/read-all`, {}),
}; };
+123 -8
View File
@@ -6,6 +6,31 @@ export interface SmokeCheckResult {
detail: string; detail: string;
} }
/**
* 冒烟用的教师凭据:**由 preload 从环境变量注入,不写死在代码里**。
*
* 本文件会进渲染进程产物(`dist/renderer/assets/*.js`)→ `app.asar` → 每台学生机,
* 而 asar 可以直接解包。因此任何口令字面量都不能留在这里;
* 凭据由 `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供
* (见 `main/smoke.ts` 顶部关于 app.asar 的完整说明)。
*/
let smokeTeacherCredentials: { username: string; password: string } = { username: '', password: '' };
/** 由 main.ts 在注册冒烟钩子时注入(来源:ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD) */
export function setSmokeTeacherCredentials(next: { username: string; password: string }): void {
smokeTeacherCredentials = {
username: next?.username ?? '',
password: next?.password ?? '',
};
}
/** 教师登录请求体;未注入凭据时返回 null(调用方据此跳过并说明原因) */
function teacherLoginBody(): { username: string; password: string } | null {
return smokeTeacherCredentials.username && smokeTeacherCredentials.password
? { username: smokeTeacherCredentials.username, password: smokeTeacherCredentials.password }
: null;
}
/** /**
* 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。 * 冒烟自检 1:IndexedDB 写入 / 读回 / 统计 / 删除。
* 由主进程通过 webContents.executeJavaScript 调用。 * 由主进程通过 webContents.executeJavaScript 调用。
@@ -144,10 +169,18 @@ async function seedSmokeContent(
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`; const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
try { try {
// 凭据由环境变量注入(理由见文件顶部):没给就明确跳过,而不是拿种子账号兜底
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法自建测试数据',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, { const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }), body: JSON.stringify(loginBody),
}).then((response) => response.json()); }).then((response) => response.json());
const token: string | undefined = login?.data?.token; const token: string | undefined = login?.data?.token;
if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` }; if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
@@ -239,10 +272,18 @@ export async function onlineScenario(credentials?: {
const detail: string[] = []; const detail: string[] = [];
try { try {
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入 // 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
const code = credentials?.code ?? 'G101'; // 这里**不再回退到种子班级码**(G101/123456 也是真实可用的登录凭据,不能写进安装包):
const password = credentials?.password ?? '123456'; // 没有凭据就明确跳过,由 scripts/smoke.mjs 打印"未能准备班级账号"。
if (!credentials?.code) detail.push('未收到班级凭据,回退到种子班级码 G101'); const code = credentials?.code ?? '';
const password = credentials?.password ?? '';
if (!code) {
return {
ok: false,
detail:
'未收到班级凭据(ELECTRON_SMOKE_CLASS_CODE / ELECTRON_SMOKE_CLASS_PASSWORD),已跳过联网集成自检',
};
}
await auth.login(serverUrl, code, password); await auth.login(serverUrl, code, password);
detail.push(`login=${auth.user?.name ?? '-'}`); detail.push(`login=${auth.user?.name ?? '-'}`);
@@ -363,10 +404,17 @@ export async function islandRealtimeScenario(): Promise<{
const title = `灵动岛真机链路自检 ${Date.now()}`; const title = `灵动岛真机链路自检 ${Date.now()}`;
try { try {
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法投递通知',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, { const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }), body: JSON.stringify(loginBody),
}).then((response) => response.json()); }).then((response) => response.json());
const teacherToken: string | undefined = login?.data?.token; const teacherToken: string | undefined = login?.data?.token;
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` }; if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
@@ -378,6 +426,8 @@ export async function islandRealtimeScenario(): Promise<{
classId, classId,
title, title,
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。', content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
// 普通优先级:冒烟客户端登录的是**临时班级**(无课表 → 判定不在上课),
// 普通通知也会立刻弹出胶囊,链路断言与真实上课时段无关。
priority: 'NORMAL', priority: 'NORMAL',
}), }),
}).then((response) => response.json()); }).then((response) => response.json());
@@ -465,10 +515,17 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
let teacherToken = ''; let teacherToken = '';
try { try {
const loginBody = teacherLoginBody();
if (!loginBody) {
return {
ok: false,
detail: '未提供冒烟教师凭据(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD),无法构造课表',
};
}
const login = await fetch(`${serverUrl}/api/auth/login`, { const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }), body: JSON.stringify(loginBody),
}).then((response) => response.json()); }).then((response) => response.json());
teacherToken = login?.data?.token ?? ''; teacherToken = login?.data?.token ?? '';
if (!teacherToken) return { ok: false, detail: '教师登录失败' }; if (!teacherToken) return { ok: false, detail: '教师登录失败' };
@@ -613,7 +670,9 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
// 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。 // 今天可能恰好没有作业:切到「最近一个有作业的日期」保证看板一定有卡片(断言才有意义)。
// 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。 // 按天查看支持 ?date=YYYY-MM-DD 深链,切天不需要去操作日期选择器。
if (!document.querySelector('.board-host')) { // 注意判据是「看板里没有卡片」而不是「没有 board-host 容器」——空看板照样渲染容器,
// 拿容器当判据会导致切日期的兜底永远不触发(今天的日期没作业时看板必然为空)。
if (!document.querySelector('.board-card')) {
const [{ homeworkApi }, { useAuthStore }] = await Promise.all([ const [{ homeworkApi }, { useAuthStore }] = await Promise.all([
import('../api/index.js'), import('../api/index.js'),
import('../stores/auth.js'), import('../stores/auth.js'),
@@ -704,6 +763,61 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
} }
} }
/**
* 冒烟自检(不需要后端):**本机录入的作业不再上灵动岛**(用户要求)。
*
* 场景:教室机器上录完作业,服务端照样广播 `homework:new`,但"自己通知自己"没有意义。
* 判定按**内容指纹 + id**(发起请求前就登记,避免"广播晚于响应"漏判),因此这里两条都要验:
* 1) 登记过的这条:投递后灵动岛状态不变;
* 2) 没登记过的同形作业(别班/别的标题):照常上岛。
*/
export async function islandHomeworkSuppressionCheck(): Promise<SmokeCheckResult> {
const [{ markHomeworkCreatedLocally, isLocalHomework, pushHomeworkToIsland }, { useAuthStore }] =
await Promise.all([import('../island/bridge.js'), import('../stores/auth.js')]);
try {
const classId = useAuthStore().classId ?? 'smoke-class';
const assignDate = '2030-01-01';
const draft = {
id: 'smoke-homework-suppressed',
classId,
title: '本机录入的作业',
content: '这条不应该弹上岛',
assignDate,
};
markHomeworkCreatedLocally(draft);
const recognized = isLocalHomework(draft);
// 同 id、同内容 → 必须认出来;换个标题 → 不算本机录入
const recognizedByFingerprint = isLocalHomework({ ...draft, id: '' });
const foreign = isLocalHomework({ ...draft, id: 'other', title: '别的作业' });
const before = await window.desktop?.islandGetState?.();
pushHomeworkToIsland({
...draft,
courseId: null,
attachmentUrl: null,
createdBy: '',
createdAt: new Date().toISOString(),
} as never);
await new Promise((resolve) => setTimeout(resolve, 400));
const after = await window.desktop?.islandGetState?.();
const untouched =
(before?.active?.id ?? null) === (after?.active?.id ?? null) &&
(before?.queued.length ?? 0) === (after?.queued.length ?? 0);
return {
ok: recognized && recognizedByFingerprint && !foreign && untouched,
detail:
`按 id 认出=${recognized} 按内容指纹认出=${recognizedByFingerprint} 别的作业误判=${foreign} ` +
`投递后灵动岛未变=${untouched}`,
};
} catch (error) {
return {
ok: false,
detail: `本机作业不上岛自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */ /** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> { export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([ const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -730,6 +844,7 @@ export function registerSmokeHooks(): void {
islandRealtimeScenario, islandRealtimeScenario,
islandRealtimeCleanup, islandRealtimeCleanup,
islandReadState, islandReadState,
islandHomeworkSuppressionCheck,
scheduleTimelineSelfTest, scheduleTimelineSelfTest,
homeworkBoardSelfTest, homeworkBoardSelfTest,
sessionCleanup, sessionCleanup,
@@ -92,6 +92,11 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */ /** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */
export function pushHomeworkToIsland(homework: HomeworkDto): void { export function pushHomeworkToIsland(homework: HomeworkDto): void {
// 本机刚录入的作业不再上岛:教室机器上录作业的人就是眼前这台设备的主人,
// 再弹一张"新作业"卡片纯属自己通知自己(用户反馈)。
if (isLocalHomework(homework)) {
return;
}
window.desktop?.islandPush({ window.desktop?.islandPush({
notification: { notification: {
id: `homework-${homework.id}`, 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)无视上课时段立即展开; * "叫人"上岛:紧急叫人(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 { export function startIslandBridge(): void {
if (tickTimer) return; if (tickTimer) return;
void refreshIslandSchedules().catch(() => undefined); void refreshIslandSchedules().catch(() => undefined);
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏" // 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, () => { useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
void refreshIslandSchedules().catch(() => undefined);
});
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS); tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
@@ -164,9 +250,31 @@ export function startIslandBridge(): void {
}, REFRESH_INTERVAL_MS); }, REFRESH_INTERVAL_MS);
} }
/**
* 停止桥接:退出登录 / 切换账号时必须调用。
*
* 只清定时器是不够的:
* - 订阅要 off,否则每次"登出 → 登录"都会多挂一份监听;
* - 本地课表缓存要清空,否则登出后仍会按**上一个班**的课表继续给主进程下发"上课/下课"
* (灵动岛按陈旧课表隐藏或弹出),换班登录时第一轮刷新回来之前用的也是旧课表。
*/
export function stopIslandBridge(): void { export function stopIslandBridge(): void {
if (tickTimer) clearInterval(tickTimer); if (tickTimer) clearInterval(tickTimer);
if (refreshTimer) clearInterval(refreshTimer); if (refreshTimer) clearInterval(refreshTimer);
tickTimer = null; tickTimer = null;
refreshTimer = null; refreshTimer = null;
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
schedules = [];
currentPeriod = null;
inClass = false;
// 把"不在上课"同步给主进程:否则主进程还记着上一个会话的"上课中",
// 重新登录后若第一轮课表刷新失败(离线且无缓存),灵动岛会继续按上课态把通知压着不弹,
// 直到下一次刷新成功为止。这里主动对齐一次,代价是登出瞬间可能补弹一条此前被暂存的通知。
window.desktop?.islandSetClassState({
inClass: false,
currentPeriodEnd: null,
week: useAppStore().currentWeek,
});
} }
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js'; import { useNotificationStore } from '../stores/notifications.js';
@@ -70,10 +71,14 @@ async function handleLogout(): Promise<void> {
void router.push({ name: 'login' }); void router.push({ name: 'login' });
} }
/** 通知实时推送:更新列表 + 红点 + 缓存 */ /**
* 通知实时推送:更新通知中心列表与红点。
*
* 这里**不再**额外弹一条"收到新通知"的 toast —— `stores/realtime.ts` 收到
* notification:new 时已经弹过带优先级与标题的那条,两条堆在屏幕上纯属噪音。
*/
function onNotification(payload: unknown): void { function onNotification(payload: unknown): void {
void notifications.pushRealtime(payload as NotificationDto); void notifications.pushRealtime(payload as NotificationDto);
ElMessage({ message: '收到新通知,点击「通知」查看', type: 'info', duration: 3000 });
} }
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */ /** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
@@ -87,11 +92,15 @@ onMounted(async () => {
await notifications.refreshUnreadCount(); await notifications.refreshUnreadCount();
realtime.on(SOCKET_EVENTS.notificationNew, onNotification); realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
appStore.onServerRecovered(onRecovered); appStore.onServerRecovered(onRecovered);
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
if (auth.token) startIslandBridge();
}); });
onUnmounted(() => { onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotification); realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
appStore.offServerRecovered(onRecovered); appStore.offServerRecovered(onRecovered);
stopIslandBridge();
}); });
</script> </script>
+7 -2
View File
@@ -7,7 +7,7 @@ import 'element-plus/dist/index.css';
import App from './App.vue'; import App from './App.vue';
import router from './router/index.js'; import router from './router/index.js';
import { registerSmokeHooks } from './cache/self-test.js'; import { registerSmokeHooks, setSmokeTeacherCredentials } from './cache/self-test.js';
import './styles/index.css'; import './styles/index.css';
const app = createApp(App); const app = createApp(App);
@@ -20,8 +20,13 @@ app.use(createPinia());
app.use(router); app.use(router);
app.use(ElementPlus, { locale: zhCn }); app.use(ElementPlus, { locale: zhCn });
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在) // 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)。
// 教师凭据由 preload 从环境变量透传(**不写在代码里**:渲染产物会进 app.asar 发给学生机)。
if (window.desktop?.smokeTest) { if (window.desktop?.smokeTest) {
setSmokeTeacherCredentials({
username: window.desktop.smokeCredentials?.username ?? '',
password: window.desktop.smokeCredentials?.password ?? '',
});
registerSmokeHooks(); registerSmokeHooks();
} }
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { import {
@@ -15,6 +15,7 @@ import {
} from '@classhelper/shared'; } from '@classhelper/shared';
import { courseApi, homeworkApi } from '../api/index.js'; import { courseApi, homeworkApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
import { markHomeworkCreatedLocally } from '../island/bridge.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
@@ -80,6 +81,17 @@ async function selectDate(value: string | null): Promise<void> {
await loadHomework(); await loadHomework();
} }
// 深链 ?date= 不止在首次挂载生效:已在作业页时(看板/列表之间切换、冒烟直接推 query),
// 路由 query 变了也要跟着切换日期 —— 组件被复用,没有这条监听就停在原日期。
watch(
() => route.query.date,
(value) => {
if (typeof value !== 'string' || !isDayKey(value) || value === selectedDate.value) return;
selectedDate.value = value;
void loadHomework();
},
);
/** 前后一天(教室电脑上不用打开日历也能翻) */ /** 前后一天(教室电脑上不用打开日历也能翻) */
async function shiftDate(delta: number): Promise<void> { async function shiftDate(delta: number): Promise<void> {
await selectDate(shiftDayKey(selectedDate.value, delta)); await selectDate(shiftDayKey(selectedDate.value, delta));
@@ -412,13 +424,19 @@ async function submitCreate(): Promise<void> {
createSaving.value = true; createSaving.value = true;
try { try {
await homeworkApi.create({ // 先按内容登记"This 是本机录的":服务端是**先广播 homework:new、后回响应**的,
// 等响应拿到 id 再登记就晚了 —— 实时事件可能已经到了,岛已经弹出来了。
const draft = { classId, title, content, assignDate: selectedDate.value };
markHomeworkCreatedLocally(draft);
const created = await homeworkApi.create({
classId, classId,
courseId: createForm.value.courseId || null, courseId: createForm.value.courseId || null,
title, title,
content, content,
assignDate: selectedDate.value, assignDate: selectedDate.value,
}); });
// 回执里带上 id 再记一次(覆盖"广播晚于响应"的另一半)
if (created?.id) markHomeworkCreatedLocally({ ...draft, id: created.id });
ElMessage.success(`已录入到 ${selectedDate.value}`); ElMessage.success(`已录入到 ${selectedDate.value}`);
createVisible.value = false; createVisible.value = false;
await loadHomework(); await loadHomework();
@@ -624,12 +624,18 @@ onMounted(async () => {
{{ auth.user?.username ?? '-' }} {{ auth.user?.username ?? '-' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="登录方式"> <el-descriptions-item label="登录方式">
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '个人学生账号' }} {{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item> <el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item> <el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-button class="mt-16" type="danger" plain @click="logout">退出登录</el-button> <div class="toolbar mt-16">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
<div class="text-muted mt-12">
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
班级密码重新登录。
</div>
</el-card> </el-card>
</el-col> </el-col>
+35 -1
View File
@@ -80,6 +80,20 @@ export interface IslandClassStatePayload {
export interface DesktopBridge { export interface DesktopBridge {
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */ /** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
smokeTest: boolean; smokeTest: boolean;
/**
* 冒烟凭据(仅冒烟模式下发,否则 `undefined`)。
*
* 为什么要从环境变量绕一圈:这些凭据被渲染进程的冒烟钩子用来"造测试数据",
* 而渲染产物(`dist/renderer/assets/*.js`)会进 `app.asar` 发给每台学生机 —— asar 可直接解包,
* 把账号口令写在代码里等于随安装包一起发出去。凭据只由
* `scripts/smoke.mjs` / `scripts/verify-packaged.mjs` 通过环境变量提供(这两个脚本不进安装包)。
*/
smokeCredentials?: {
username: string;
password: string;
classCode: string;
classPassword: string;
};
getConfig(): Promise<DesktopStoredConfig>; getConfig(): Promise<DesktopStoredConfig>;
/** /**
* 局部更新配置。 * 局部更新配置。
@@ -113,6 +127,8 @@ export interface DesktopBridge {
islandGetAppearance(): Promise<IslandAppearance>; islandGetAppearance(): Promise<IslandAppearance>;
/** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */ /** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */
onIslandMarkRead(handler: (id: string) => void): void; onIslandMarkRead(handler: (id: string) => void): void;
/** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */
onIslandMarkAllRead(handler: (ids: string[]) => void): void;
} }
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */ /** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
@@ -121,7 +137,18 @@ export interface IslandRendererBridge {
/** 订阅外观设置(CSS 变量实时生效) */ /** 订阅外观设置(CSS 变量实时生效) */
onAppearance(handler: (appearance: IslandAppearance) => void): void; onAppearance(handler: (appearance: IslandAppearance) => void): void;
getAppearance(): Promise<IslandAppearance>; getAppearance(): Promise<IslandAppearance>;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; sendAction(
action:
| 'expand'
| 'expand-list'
| 'collapse'
| 'dismiss'
| 'dismiss-all'
| 'mark-read'
| 'mark-all-read'
| 'open-app',
id?: string,
): void;
getState(): Promise<IslandState>; getState(): Promise<IslandState>;
/** /**
* 命中提示:指针在岛体上时应让固定大窗口接收鼠标(比主进程 60ms 轮询更快)。 * 命中提示:指针在岛体上时应让固定大窗口接收鼠标(比主进程 60ms 轮询更快)。
@@ -131,6 +158,11 @@ export interface IslandRendererBridge {
setInteractive(interactive: boolean): void; setInteractive(interactive: boolean): void;
/** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */ /** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */
setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void; setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void;
/**
* 上报"本机是触摸屏"(`navigator.maxTouchPoints > 0`):主进程据此让窗口贴合岛体并始终接收
* 输入,否则触摸屏上手指永远点不到岛(触摸不产生 mousemove、也不移动系统光标)。
*/
setTouchMode(enabled: boolean): void;
/** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */ /** 心跳:主进程据此发现"渲染进程卡死的幽灵窗口"(岛还在屏幕上但点不动)并重建窗口 */
alive(): void; alive(): void;
} }
@@ -175,6 +207,8 @@ declare global {
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>; scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 作业看板全屏自适应自检(切看板 → 打开全屏 → 量尺寸) */ /** 作业看板全屏自适应自检(切看板 → 打开全屏 → 量尺寸) */
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>; homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */
islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>; sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
}; };
} }