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

@@ -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);