feat: 客户端灵动岛 + 上课时段紧急通知二次确认,并修复打包/校验脚本在 Electron 宿主下的多个缺陷

## 客户端灵动岛(动态岛)

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

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

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

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

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

## 验证结果

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

No files matched your search

+108
View File
@@ -440,6 +440,114 @@ async function main() {
`可见班级 ${dashboardForStudent.payload?.data?.classCount ?? 0} 个`,
);
// ---------------------------------------------------------------- 6.2 上课时段与紧急通知二次确认
// 诊断参数 at:2026-09-07 是周一,种子数据里周一 08:00-08:45 有课
const mondayInClass = await api(`/schedules/current?classId=${classId}&at=2026-09-07T08:10:00`, {
token: teacherToken,
});
record(
'上课状态接口(at=周一 08:10 判定为上课)',
mondayInClass.status === 200 && mondayInClass.payload?.data?.inClass === true,
`course=${mondayInClass.payload?.data?.current?.courseName ?? '-'} week=${mondayInClass.payload?.data?.week ?? '-'}`,
);
const sundayFree = await api(`/schedules/current?classId=${classId}&at=2026-09-06T03:00:00`, {
token: teacherToken,
});
record(
'上课状态接口(at=周日 03:00 判定为不在上课)',
sundayFree.status === 200 && sundayFree.payload?.data?.inClass === false,
`inClass=${sundayFree.payload?.data?.inClass}`,
);
// 造一节覆盖"此刻"的课,让紧急通知拦截用例与运行时刻无关
const nowDate = new Date();
const toHHmm = (minutes) => {
const clamped = Math.min(23 * 60 + 59, Math.max(0, minutes));
return `${String(Math.floor(clamped / 60)).padStart(2, '0')}:${String(clamped % 60).padStart(2, '0')}`;
};
const nowMinutes = nowDate.getHours() * 60 + nowDate.getMinutes();
const probeStart = toHHmm(nowMinutes - 30);
const probeEnd = toHHmm(nowMinutes + 30);
const todayWeekday = nowDate.getDay() === 0 ? 7 : nowDate.getDay();
const probeSchedule = await api('/schedules', {
method: 'POST',
token: teacherToken,
body: {
classId,
courseId,
dayOfWeek: todayWeekday,
startTime: probeStart,
endTime: probeEnd,
location: '联调验证教室',
weekStart: 1,
weekEnd: 30,
},
});
const probeScheduleId = probeSchedule.payload?.data?.id;
record('创建覆盖当前时刻的课表(构造上课场景)', probeSchedule.status === 201, `${probeStart}-${probeEnd}`);
const realTimeStatus = await api(`/schedules/current?classId=${classId}`, { token: teacherToken });
record(
'当前时刻被判定为上课时间段',
realTimeStatus.status === 200 && realTimeStatus.payload?.data?.inClass === true,
`course=${realTimeStatus.payload?.data?.current?.courseName ?? '-'}`,
);
const blockedUrgent = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: '上课时段紧急通知(未确认)',
content: '这条应当被服务端拦截',
priority: 'URGENT',
},
});
record(
'上课时段发布紧急通知被拦截(409 URGENT_DURING_CLASS)',
blockedUrgent.status === 409 && blockedUrgent.payload?.code === 'URGENT_DURING_CLASS',
`status=${blockedUrgent.status} code=${blockedUrgent.payload?.code ?? '-'} 当前课程=${blockedUrgent.payload?.details?.current?.courseName ?? '-'}`,
);
const confirmedUrgent = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: `上课时段紧急通知(已二次确认)${Date.now()}`,
content: '教师已在上课警告中确认,允许发布',
priority: 'URGENT',
confirmDuringClass: true,
},
});
record('二次确认后允许发布紧急通知', confirmedUrgent.status === 201, `status=${confirmedUrgent.status}`);
const normalDuringClass = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: {
classId,
title: `上课时段普通通知${Date.now()}`,
content: '普通通知不受上课时段限制',
priority: 'NORMAL',
},
});
record(
'上课时段发布普通通知不受限制',
normalDuringClass.status === 201,
`status=${normalDuringClass.status}`,
);
if (probeScheduleId) {
const removedProbe = await api(`/schedules/${probeScheduleId}`, {
method: 'DELETE',
token: teacherToken,
});
record('清理探测课表', removedProbe.status === 200, `status=${removedProbe.status}`);
}
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
+3
View File
@@ -19,6 +19,9 @@ export type ApiErrorCode =
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'TOO_MANY_REQUESTS'
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
| 'URGENT_DURING_CLASS'
| 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */
+6 -16
View File
@@ -1,23 +1,13 @@
import { MAX_TERM_WEEK, resolveCurrentWeek as resolveWeek } from '@classhelper/shared';
import { env } from '../config/env.js';
const MS_PER_DAY = 86_400_000;
export const MAX_TERM_WEEK = 30;
export { MAX_TERM_WEEK };
/**
* 依据 TERM_START_DATE 计算当前教学周(第 1 周从该日期所在周一开始)。
* 学生端与 Web 端"当前周次"默认值都走这里,保证三端口径一致。
* 当前教学周。
* 具体计算逻辑在 @classhelper/shared(三端共用),这里只负责注入服务端的
* TERM_START_DATE 与周次上限,避免"周次"口径在服务端/客户端出现分叉。
*/
export function resolveCurrentWeek(now: Date = new Date()): number {
if (!env.termStartDate) return 1;
const start = new Date(env.termStartDate);
if (Number.isNaN(start.getTime())) return 1;
// 归一到该日期所在周的周一
const weekday = start.getDay() === 0 ? 7 : start.getDay();
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
monday.setHours(0, 0, 0, 0);
const diffDays = Math.floor((now.getTime() - monday.getTime()) / MS_PER_DAY);
const week = Math.floor(diffDays / 7) + 1;
return Math.min(Math.max(week, 1), MAX_TERM_WEEK);
return resolveWeek(env.termStartDate, now, MAX_TERM_WEEK);
}
@@ -15,6 +15,11 @@ export const createNotificationSchema = z.object({
title: z.string().trim().min(1, '请输入通知标题').max(120),
content: z.string().min(1, '请输入通知内容').max(5000),
priority: notificationPrioritySchema.optional(),
/**
* 上课时间段内发布"紧急"通知时必须显式确认(教师已在全屏警告中二次确认)。
* 未确认时服务端返回 409 URGENT_DURING_CLASS 及当前课程信息。
*/
confirmDuringClass: z.boolean().optional(),
});
export const notificationScopeQuerySchema = z.object({
@@ -8,8 +8,10 @@ import {
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { toNotificationDto } from '../../lib/mappers.js';
import { emitToClass } from '../../realtime/bus.js';
import { computeClassStatus } from '../schedules/schedules.service.js';
import type { CreateNotificationInput } from './notifications.schemas.js';
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
@@ -48,19 +50,42 @@ export async function listNotifications(
return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff }));
}
/** 发布通知并实时广播到 class:{classId} 房间 */
/**
* 发布通知并实时广播到 class:{classId} 房间。
*
* 上课时段保护:若目标班级当前正在上课且优先级为"紧急",必须由前端二次确认
* (`confirmDuringClass: true`)才允许发布,否则返回 409 URGENT_DURING_CLASS,
* 由 Web 端弹出全屏警告并倒计时 3 秒。
*/
export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertClassWritable(user, input.classId);
const priority = input.priority ?? 'NORMAL';
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
const status = await computeClassStatus(input.classId);
if (status.inClass) {
logger.warn(
`上课时段发布紧急通知被拦截:班级=${input.classId} 课程=${status.current?.courseName ?? '-'}(等待教师二次确认)`,
);
throw new ApiError(409, 'URGENT_DURING_CLASS', '现在为上课时间段,发布紧急通知会干扰上课,请再次确认', {
classId: input.classId,
current: status.current,
next: status.next,
week: status.week,
serverTime: status.serverTime,
});
}
}
const created = await prisma.notification.create({
data: {
classId: input.classId,
title: input.title,
content: input.content,
priority: input.priority ?? 'NORMAL',
priority,
createdBy: user.sub,
},
include: { creator: creatorSelect, reads: true },
@@ -1,11 +1,13 @@
import { Router } from 'express';
import { resolveCurrentWeek } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { resolveCurrentWeek } from '../../lib/term.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
classStatusQuerySchema,
createScheduleSchema,
gridQuerySchema,
listSchedulesQuerySchema,
@@ -31,11 +33,29 @@ router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(
res,
await scheduleService.getScheduleGrid(user, { classId, week: week ?? resolveCurrentWeek() }),
await scheduleService.getScheduleGrid(user, {
classId,
week: week ?? resolveCurrentWeek(env.termStartDate),
}),
'获取周视图成功',
);
});
/**
* GET /api/schedules/current?classId=&at= - 班级当前上课状态。
* 客户端灵动岛(上课期间隐藏、下课后弹出)与 Web 端"上课时段发布紧急通知"
* 的全屏二次确认都基于它;`at` 为诊断/联调用时间覆盖。
*/
router.get('/current', validate({ query: classStatusQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId, at } = validatedQuery<{ classId?: string; at?: string }>(req);
const status = await scheduleService.getClassStatus(user, {
classId,
at: at ? new Date(at) : undefined,
});
sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段');
});
/** POST /api/schedules - 新增课表 */
router.post(
'/',
@@ -13,6 +13,20 @@ export const gridQuerySchema = z.object({
week: weekNumberSchema.optional(),
});
/**
* 上课状态查询。
* `at` 为诊断/联调用的时间覆盖(例如排查"为什么现在不认为是上课"),
* 不传则按服务器当前时间判定。
*/
export const classStatusQuerySchema = z.object({
classId: z.string().min(1).optional(),
at: z
.string()
.min(1)
.optional()
.refine((value) => value === undefined || !Number.isNaN(new Date(value).getTime()), 'at 需为合法时间'),
});
export const createScheduleSchema = z
.object({
classId: z.string().min(1, '请选择班级'),
@@ -1,15 +1,25 @@
import {
SOCKET_EVENTS,
buildScheduleWeekView,
resolveClassStatus,
resolveCurrentWeek,
type ClassStatusDto,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import {
assertClassAccess,
assertClassWritable,
classScopeWhere,
isStudent,
requireStudentClassId,
resolveClassScope,
} from '../../lib/access.js';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toScheduleDto } from '../../lib/mappers.js';
import { resolveCurrentWeek } from '../../lib/term.js';
import { emitToClass } from '../../realtime/bus.js';
import type { CreateScheduleInput, UpdateScheduleInput } from './schedules.schemas.js';
@@ -47,11 +57,50 @@ export async function getScheduleGrid(
user: TokenPayload,
options: { classId?: string; week?: number },
): Promise<ScheduleWeekView> {
const week = options.week ?? resolveCurrentWeek();
const week = options.week ?? resolveCurrentWeek(env.termStartDate);
const items = await listSchedules(user, { classId: options.classId, week });
return buildScheduleWeekView(items, week);
}
/**
* 计算指定班级在某时刻的上课状态(不做权限校验,供服务端内部复用)。
* @param at 判定时刻,默认当前时间(接口层可传入用于诊断/测试)
*/
export async function computeClassStatus(classId: string, at?: Date): Promise<ClassStatusDto> {
const now = at ?? new Date();
const week = resolveCurrentWeek(env.termStartDate, now);
const schedules = await prisma.schedule.findMany({
where: { classId },
include: { course: courseSelect },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
});
const status = resolveClassStatus(schedules.map(toScheduleDto), now, week);
return {
classId,
inClass: status.inClass,
current: status.current,
next: status.next,
serverTime: now.toISOString(),
week,
};
}
/**
* 班级上课状态(带权限校验)。
* - 学生:固定为自己所在班级
* - 教师/管理员:需要显式 classId(前端发布通知时使用)
*/
export async function getClassStatus(
user: TokenPayload,
options: { classId?: string; at?: Date } = {},
): Promise<ClassStatusDto> {
const classId = isStudent(user) ? requireStudentClassId(user) : options.classId;
if (!classId) throw ApiError.badRequest('请指定班级(classId)');
await assertClassAccess(user, classId);
return computeClassStatus(classId, options.at);
}
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
await assertClassWritable(user, input.classId);
await assertCourseInClass(input.courseId, input.classId);