Files
ClassHelper/scripts/ui-smoke/live-probe.mjs
T
LoveChengke 3a9f8812fc feat: 客户端灵动岛 + 上课时段紧急通知二次确认,并修复打包/校验脚本在 Electron 宿主下的多个缺陷
## 客户端灵动岛(动态岛)

- 新增独立透明置顶窗口:桌面中上方常驻、不占任务栏、不抢焦点(268x44 胶囊 / 404x316 详情 / 424x344 紧急)
- 三种形态与动画:隐藏 ↔ 胶囊 ↔ 详情,尺寸用 easeOutCubic 260ms 逐帧缓动、透明度单独淡入淡出
  (尺寸与淡入使用各自动画令牌,避免互相打断导致窗口卡在胶囊尺寸)
- 行为:普通通知变「新消息 · 共 N 条」胶囊,点击展开详情;上课时段自动隐藏并进入待发队列,
  下课后自动弹出;紧急通知无论是否上课立即展开(红色呼吸光晕 + 紧急角标,无需点击)
- 上课状态来自 GET /api/schedules/current(10s 心跳 + 下课时长定时器),
  新增 src/main/island.ts(控制器/IPC)、src/preload/island.ts、src/island/IslandApp.vue、
  src/renderer/island/bridge.ts、island.html 与多页 Vite 构建

## 上课时段发布紧急通知的二次确认

- 服务端:priority=URGENT 且当前处于上课时间段且未带 confirmDuringClass 时返回
  409 URGENT_DURING_CLASS(携带 current/next/week/serverTime),绕过界面调接口同样被拦截
- 新增 GET /api/schedules/current(inClass/current/next,at 参数用于诊断与测试)
- Web 管理端:全屏警告遮罩(UrgentClassWarning.vue),显示正在上的课时段与待发布标题,
  确认按钮带 3 秒倒计时(圆环进度,倒计时结束前禁用),取消即不发;并发命中 409 同样兜底弹窗

## 打包与验证脚本修复(Electron 宿主环境)

- scripts/lib/node-runtime.mjs:定位真正的 node.exe。此前经 pnpm 运行时 process.execPath 指向
  Electron,dist:server 会把 Electron 复制成安装包内的 node.exe,dist:win 会让 electron-builder 报
  Unknown argument: .../cli.js(改为显式使用真实 Node)
- scripts/lib/electron-env.mjs:启动 Electron 前清理继承的 ELECTRON_RUN_AS_NODE(否则 Electron 退化为
  纯 Node,require('electron') 直接 MODULE_NOT_FOUND),并从 path.txt 定位 electron.exe
  (process.versions.electron 存在时 import 'electron' 返回的是 API 对象而非路径)
- scripts/dist-server.mjs:新增构建产物新鲜度校验(曾出现 server 已重建但打进安装包的 shared/dist 是旧的,
  安装后服务启动即报 does not provide an export named MAX_TERM_WEEK)
- scripts/ui-smoke:新增 live-probe.mjs 真实制造/清理上课时段,Electron 驱动真实点击断言全屏警告文案、
  按钮禁用与 3 秒倒计时时长(实测 2966ms),测试后自动删除探针课表与测试通知

## 验证结果

- pnpm verify:e2e 62/62(新增上课时段拦截、二次确认后发布、普通通知不受限等 8 项)
- pnpm verify:web 8/8(新增上课时段紧急通知全屏警告 + 倒计时)
- pnpm verify:desktop 18/18(新增灵动岛窗口/上课隐藏/下课弹出/紧急立即展开/胶囊点击展开 + 截图)
- 打包后 EXE 冒烟 15/15(packaged: true,退出码 0)
- 服务端安装程序升级安装:.env 与数据库保持不变,/api/schedules/current 正常
- pnpm lint / format:check / typecheck 全部通过
2026-09-12 08:41:08 +08:00

150 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* UI 冒烟测试用「真实上课时段探针」。
*
* 为了验证「上课时段发布紧急通知 → 全屏警告 + 3 秒倒计时」这条链路,
* 测试必须先让服务端真实认定"现在正在上课"。本模块直接用 HTTP 调后端接口:
*
* 1. 登录(拿到 token)
* 2. 取该教师第一个班级 + 该班级第一门课程
* 3. 新建一条"今天 00:00-23:59、第 1-30 教学周"的课表 —— 覆盖当前时刻
* 4. 调 /api/schedules/current 确认服务端确实返回 inClass = true
*
* 测试结束后 cleanup 会删除这条探针课表,并删除本次测试发布的通知,
* 保证不污染演示数据。
*
* 注意:这里只使用 Node 内置 fetch(Node 20+),不引入额外依赖。
*/
const DEFAULT_TIMEOUT_MS = 10_000;
async function call(baseUrl, path, { method = 'GET', token, body } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
try {
const response = await fetch(`${baseUrl}${path}`, {
method,
signal: controller.signal,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = { raw: text.slice(0, 200) };
}
return { status: response.status, payload };
} finally {
clearTimeout(timer);
}
}
/** 今天对应的课表星期(1=周一 … 7=周日) */
function todayDayOfWeek(now = new Date()) {
const weekday = now.getDay();
return weekday === 0 ? 7 : weekday;
}
/**
* 制造一次真实的上课时段。
* @returns {Promise<{token: string, classId: string, className: string, courseId: string,
* scheduleId: string, inClass: boolean, period: object|null}>}
*/
export async function setupInClassProbe({ baseUrl, username = 'teacher1', password = 'teacher123' } = {}) {
const base = (baseUrl ?? 'http://127.0.0.1:4000').replace(/\/$/, '');
const login = await call(base, '/api/auth/login', {
method: 'POST',
body: { username, password },
});
const token = login.payload?.data?.token;
if (login.status !== 200 || !token) {
throw new Error(`探针登录失败:HTTP ${login.status} ${JSON.stringify(login.payload)?.slice(0, 160)}`);
}
const classes = await call(base, '/api/classes', { token });
const targetClass = Array.isArray(classes.payload?.data) ? classes.payload.data[0] : null;
if (!targetClass) throw new Error('探针失败:该教师名下没有班级,无法制造上课时段');
const courses = await call(base, `/api/courses?classId=${encodeURIComponent(targetClass.id)}`, {
token,
});
const targetCourse = Array.isArray(courses.payload?.data) ? courses.payload.data[0] : null;
if (!targetCourse) throw new Error(`探针失败:班级「${targetClass.name}」没有课程,无法制造上课时段`);
const schedule = await call(base, '/api/schedules', {
method: 'POST',
token,
body: {
classId: targetClass.id,
courseId: targetCourse.id,
dayOfWeek: todayDayOfWeek(),
startTime: '00:00',
endTime: '23:59',
weekStart: 1,
weekEnd: 30,
location: 'UI 冒烟探针',
},
});
const scheduleId = schedule.payload?.data?.id;
if (schedule.status !== 201 || !scheduleId) {
throw new Error(
`探针失败:创建课表返回 HTTP ${schedule.status} ${JSON.stringify(schedule.payload)?.slice(0, 200)}`,
);
}
const status = await call(base, `/api/schedules/current?classId=${encodeURIComponent(targetClass.id)}`, {
token,
});
return {
baseUrl: base,
token,
classId: targetClass.id,
className: targetClass.name,
courseId: targetCourse.id,
scheduleId,
inClass: Boolean(status.payload?.data?.inClass),
period: status.payload?.data?.current ?? null,
statusCode: status.status,
};
}
/** 删除探针课表 + 本次测试发布的通知,恢复原始数据 */
export async function cleanupInClassProbe(probe, { notificationTitle } = {}) {
const removed = [];
if (!probe?.baseUrl || !probe?.token) return removed;
if (notificationTitle) {
const list = await call(
probe.baseUrl,
`/api/notifications?classId=${encodeURIComponent(probe.classId)}&keyword=${encodeURIComponent(notificationTitle)}`,
{ token: probe.token },
);
const matched = Array.isArray(list.payload?.data)
? list.payload.data.filter((item) => item.title === notificationTitle)
: [];
for (const item of matched) {
const result = await call(probe.baseUrl, `/api/notifications/${item.id}`, {
method: 'DELETE',
token: probe.token,
});
if (result.status === 200) removed.push(`notification:${item.id}`);
}
}
if (probe.scheduleId) {
const result = await call(probe.baseUrl, `/api/schedules/${probe.scheduleId}`, {
method: 'DELETE',
token: probe.token,
});
if (result.status === 200) removed.push(`schedule:${probe.scheduleId}`);
}
return removed;
}