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

+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班级小助手 · 灵动岛</title>
<style>
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
html,
body {
margin: 0;
padding: 0;
background: transparent;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/island/main.ts"></script>
</body>
</html>
@@ -34,4 +34,11 @@ await build({
outfile: path.join(root, 'dist/preload/index.js'),
});
console.log('[build:main] dist/main/index.js 与 dist/preload/index.js 构建完成');
// 灵动岛窗口的独立 preload(只暴露状态订阅与操作转发)
await build({
...shared,
entryPoints: [path.join(root, 'src/preload/island.ts')],
outfile: path.join(root, 'dist/preload/island.js'),
});
console.log('[build:main] dist/main/index.js、dist/preload/index.js、dist/preload/island.js 构建完成');
+3 -2
View File
@@ -8,9 +8,10 @@ import { spawn } from 'node:child_process';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import electronPath from 'electron';
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron'));
const HOST = '127.0.0.1';
const PORT = 5174;
const DEV_URL = `http://${HOST}:${PORT}`;
@@ -53,7 +54,7 @@ function startElectron() {
electron = spawn(electronPath, ['.'], {
cwd: root,
stdio: 'inherit',
env: { ...process.env, VITE_DEV_SERVER_URL: DEV_URL },
env: resolveElectronEnv({ VITE_DEV_SERVER_URL: DEV_URL }),
});
electron.on('exit', (code) => cleanup(code ?? 0));
}
+9 -1
View File
@@ -16,6 +16,7 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..');
@@ -52,7 +53,14 @@ if (!fs.existsSync(cli)) {
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
const child = spawn(process.execPath, cliArgs, { cwd: root, stdio: 'inherit', env });
// 必须用真正的 node 执行 electron-builder CLI:
// 若宿主终端是 Electron(本机 pnpm 即如此),process.execPath 会指向 Electron,
// 直接 spawn 会让 Electron 把 cli.js 当成应用入口,多出一个位置参数,
// electron-builder 报 `Unknown argument: .../cli.js`。
const runtime = resolveNodeRuntime();
console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
child.on('exit', (code) => {
if (code === 0) {
+3 -2
View File
@@ -9,9 +9,10 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import electronPath from 'electron';
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron'));
const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
const missing = required.filter((file) => !fs.existsSync(file));
@@ -47,7 +48,7 @@ if (online === undefined) {
const child = spawn(electronPath, ['.'], {
cwd: root,
stdio: 'inherit',
env: { ...process.env, ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online },
env: resolveElectronEnv({ ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_ONLINE: online }),
});
child.on('exit', (code) => {
@@ -0,0 +1,600 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { PRIORITY_LABELS, formatDate, type IslandNotification, type IslandState } from '@classhelper/shared';
/**
* 灵动岛渲染进程。
*
* 窗口尺寸由主进程按状态缓动(见 src/main/island.ts),这里只负责:
* - 按状态渲染 胶囊 / 展开卡片
* - 内容交叉淡入与弹簧缩放(形变动画的"内容层")
* - 把用户点击(展开 / 收起 / 标为已读 / 打开应用)回传主进程
*/
const state = ref<IslandState>({
mode: 'hidden',
active: null,
queued: [],
inClass: false,
currentPeriodEnd: null,
reason: null,
updatedAt: 0,
});
const bridge = window.island;
const notification = computed<IslandNotification | null>(() => state.value.active);
const mode = computed(() => state.value.mode);
const isUrgent = computed(() => notification.value?.priority === 'URGENT');
const queueCount = computed(() => state.value.queued.length);
const priorityLabel = computed(() =>
notification.value ? (PRIORITY_LABELS[notification.value.priority] ?? notification.value.priority) : '',
);
const subtitle = computed(() => {
if (!notification.value) return '';
const teacher = notification.value.teacherName ? `${notification.value.teacherName} · ` : '';
return `${teacher}${formatDate(notification.value.createdAt, true)}`;
});
/** 下课补发时提示"其实上课期间就到了" */
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
function expand(): void {
bridge?.sendAction('expand');
}
function collapse(): void {
bridge?.sendAction('collapse');
}
function dismiss(): void {
bridge?.sendAction('dismiss');
}
function markRead(): void {
if (notification.value) bridge?.sendAction('mark-read', notification.value.id);
}
function openApp(): void {
bridge?.sendAction('open-app');
}
onMounted(async () => {
bridge?.onState((next) => {
state.value = next;
});
const initial = await bridge?.getState();
if (initial) state.value = initial;
});
</script>
<template>
<div class="island-root">
<Transition name="island-pop" :duration="{ enter: 240, leave: 150 }">
<!-- 胶囊态:收到通知后先变成"新消息" -->
<button
v-if="mode === 'pill' && notification"
key="pill"
type="button"
class="island-card pill"
:class="{ urgent: isUrgent, 'after-class': showAfterClassHint }"
@click="expand"
>
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
<span class="pulse-dot" :class="{ urgent: isUrgent }"></span>
<span class="pill-icon">
<svg viewBox="0 0 24 24" width="15" height="15" 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="pill-text">
<span class="pill-title">
{{ isUrgent ? '紧急通知' : '新消息' }}
<template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template>
</span>
<span class="pill-sub">{{ notification.teacherName || '老师' }} · 点击查看</span>
</span>
<span class="pill-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14">
<path
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
d="m9 6 6 6-6 6"
/>
</svg>
</span>
</button>
<!-- 展开态:显示通知详情 -->
<section
v-else-if="mode === 'expanded' && notification"
key="expanded"
class="island-card expanded"
:class="{ urgent: isUrgent }"
>
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
<header class="head">
<span class="head-icon" :class="{ urgent: isUrgent }">
<svg viewBox="0 0 24 24" width="15" height="15" 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>
<div class="head-meta">
<span class="badge" :class="`badge-${notification.priority.toLowerCase()}`">
{{ priorityLabel }}
</span>
<span v-if="isUrgent" class="live-dot">需立即查看</span>
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
<span class="head-sub">{{ subtitle }}</span>
</div>
<button type="button" class="icon-btn" title="收起" @click="collapse">
<svg viewBox="0 0 24 24" width="14" height="14">
<path
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
d="m6 15 6-6 6 6"
/>
</svg>
</button>
</header>
<h3 class="title">{{ notification.title }}</h3>
<div class="body">
<p class="content">{{ notification.content }}</p>
</div>
<footer class="foot">
<span v-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
<span v-else class="more muted">来自班级小助手</span>
<span class="actions">
<button type="button" class="ghost-btn" @click="openApp">打开应用</button>
<button type="button" class="ghost-btn" @click="markRead">标为已读</button>
<button type="button" class="solid-btn" @click="dismiss">知道了</button>
</span>
</footer>
</section>
<!-- 隐藏态占位(窗口不可见,仅保证 DOM 结构稳定) -->
<div v-else key="hidden" class="island-card hidden-placeholder"></div>
</Transition>
</div>
</template>
<style>
html,
body,
#app {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: transparent;
overflow: hidden;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
-webkit-user-select: none;
user-select: none;
}
.island-root {
position: relative;
width: 100%;
height: 100%;
padding: 2px 4px;
box-sizing: border-box;
}
/* 三态卡片绝对定位,使交叉淡入淡出(并行 Transition)不会互相挤压 */
.island-card {
position: absolute;
top: 2px;
right: 4px;
bottom: 2px;
left: 4px;
box-sizing: border-box;
border-radius: 24px;
color: #f7f8fa;
border: 1px solid rgba(255, 255, 255, 0.14);
background:
radial-gradient(120% 140% at 50% -20%, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 55%),
linear-gradient(180deg, #23242a 0%, #0c0d10 100%);
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
transition:
border-color 0.28s ease,
box-shadow 0.28s ease,
background 0.28s ease;
}
/* 紧急通知:红色描边 + 独立光晕层呼吸
注意:无限动画必须放在内部 .glow 上,否则会让 Vue <Transition> 的
transitionend/animationend 判定失效,导致DOM 卡在离场状态。 */
.island-card.urgent {
border-color: rgba(255, 92, 92, 0.55);
box-shadow:
0 12px 30px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 92, 92, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.glow {
position: absolute;
inset: -1px;
border-radius: inherit;
pointer-events: none;
animation: urgent-breath 2.2s ease-in-out infinite;
}
@keyframes urgent-breath {
0%,
100% {
box-shadow:
0 0 0 1px rgba(255, 92, 92, 0.22),
0 0 18px rgba(255, 72, 72, 0.28);
}
50% {
box-shadow:
0 0 0 1px rgba(255, 92, 92, 0.38),
0 0 34px rgba(255, 72, 72, 0.5);
}
}
/* ---------------------------------------------------------------- 胶囊态 */
.island-card.pill {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
border-radius: 22px;
cursor: pointer;
font: inherit;
text-align: left;
outline: none;
transition:
transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.2s ease;
}
.island-card.pill:hover {
transform: translateY(-1px) scale(1.015);
}
.island-card.pill:active {
transform: scale(0.985);
}
.hidden-placeholder {
opacity: 0;
cursor: default;
}
.pulse-dot {
position: absolute;
top: 9px;
left: 14px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #4facfe;
box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.6);
animation: dot-pulse 1.8s ease-out infinite;
}
.pulse-dot.urgent {
background: #ff5c5c;
box-shadow: 0 0 0 0 rgba(255, 92, 92, 0.7);
}
@keyframes dot-pulse {
0% {
box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.55);
}
70% {
box-shadow: 0 0 0 9px rgba(79, 172, 254, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(79, 172, 254, 0);
}
}
.pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: #9fd0ff;
background: rgba(79, 172, 254, 0.14);
flex: 0 0 auto;
}
.island-card.pill.urgent .pill-icon {
color: #ffb3b3;
background: rgba(255, 92, 92, 0.18);
}
.pill-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
line-height: 1.25;
}
.pill-title {
font-size: 13px;
font-weight: 650;
letter-spacing: 0.2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pill-sub {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pill-chevron {
color: rgba(255, 255, 255, 0.4);
flex: 0 0 auto;
}
/* ---------------------------------------------------------------- 展开态 */
.island-card.expanded {
display: flex;
flex-direction: column;
padding: 12px 14px 12px;
border-radius: 24px;
gap: 8px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.head-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 8px;
color: #9fd0ff;
background: rgba(79, 172, 254, 0.16);
flex: 0 0 auto;
}
.head-icon.urgent {
color: #ffc0c0;
background: rgba(255, 92, 92, 0.2);
}
.head-meta {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.badge {
font-size: 10px;
padding: 1px 7px;
border-radius: 999px;
font-weight: 650;
letter-spacing: 0.3px;
}
.badge-urgent {
color: #fff;
background: #ff4d4f;
}
.badge-high {
color: #3a2a00;
background: #ffc53d;
}
.badge-normal {
color: #06263f;
background: #6cc4ff;
}
.badge-low {
color: #23262b;
background: #c9ced6;
}
.live-dot {
font-size: 10px;
color: #ffb3b3;
display: inline-flex;
align-items: center;
gap: 4px;
}
.live-dot::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
animation: dot-pulse 1.6s ease-out infinite;
}
.live-dot.muted {
color: rgba(255, 255, 255, 0.45);
}
.head-sub {
margin-left: auto;
font-size: 10px;
color: rgba(255, 255, 255, 0.42);
white-space: nowrap;
}
.icon-btn {
border: none;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
width: 22px;
height: 22px;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background 0.18s ease,
transform 0.18s ease;
flex: 0 0 auto;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.16);
transform: scale(1.06);
}
.title {
margin: 0;
font-size: 15px;
font-weight: 680;
line-height: 1.35;
letter-spacing: 0.2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
background: rgba(255, 255, 255, 0.045);
border-radius: 12px;
padding: 9px 11px;
}
.body::-webkit-scrollbar {
width: 5px;
}
.body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 3px;
}
.content {
margin: 0;
font-size: 12.5px;
line-height: 1.72;
color: rgba(255, 255, 255, 0.82);
white-space: pre-wrap;
word-break: break-word;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.more {
font-size: 11px;
color: #9fd0ff;
}
.more.muted {
color: rgba(255, 255, 255, 0.38);
}
.actions {
display: inline-flex;
gap: 6px;
}
.ghost-btn,
.solid-btn {
font: inherit;
font-size: 11.5px;
border-radius: 999px;
padding: 4px 11px;
cursor: pointer;
border: 1px solid transparent;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.ghost-btn {
color: rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.08);
}
.ghost-btn:hover {
background: rgba(255, 255, 255, 0.16);
transform: translateY(-1px);
}
.solid-btn {
color: #06263f;
background: #6cc4ff;
font-weight: 650;
}
.island-card.expanded.urgent .solid-btn {
color: #fff;
background: #ff4d4f;
}
.solid-btn:hover {
transform: translateY(-1px);
}
/* ---------------------------------------------------------------- 形变动画 */
.island-pop-enter-active {
transition:
opacity 0.22s ease,
transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.island-pop-leave-active {
transition:
opacity 0.16s ease,
transform 0.2s ease;
}
.island-pop-enter-from {
opacity: 0;
transform: scale(0.86) translateY(-8px);
}
.island-pop-leave-to {
opacity: 0;
transform: scale(0.9);
}
</style>
@@ -0,0 +1,15 @@
import { createApp } from 'vue';
import IslandApp from './IslandApp.vue';
/**
* 灵动岛渲染进程入口。
* 刻意不引入 Element Plus 等 UI 库:这个窗口极小、常驻桌面,
* 保持零依赖可以让它在低配机器上也瞬间渲染。
*/
const app = createApp(IslandApp);
app.config.errorHandler = (error) => {
console.error('[island] 渲染异常', error);
};
app.mount('#app');
+22
View File
@@ -1,6 +1,8 @@
import path from 'node:path';
import { BrowserWindow, app, shell } from 'electron';
import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js';
import { runSmokeTest } from './smoke.js';
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
@@ -75,10 +77,25 @@ if (!gotLock) {
app.whenReady().then(async () => {
registerIpcHandlers();
registerIslandIpc();
mainWindow = createWindow();
await loadRenderer(mainWindow);
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
try {
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
island.setOpenAppHandler(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
} catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
@@ -90,9 +107,14 @@ if (!gotLock) {
});
app.on('window-all-closed', () => {
island.destroy();
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => {
island.destroy();
});
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
+506
View File
@@ -0,0 +1,506 @@
import path from 'node:path';
import { BrowserWindow, ipcMain, screen } from 'electron';
import type { IslandMode, IslandNotification, IslandState } from '@classhelper/shared';
import { logger } from './logger.js';
/**
* 灵动岛(Dynamic Island)主进程控制器。
*
* 形态:一个独立的、无边框、透明、置顶、不占任务栏、不抢焦点的小窗口,
* 固定在主显示器工作区顶部居中("桌面中上方")。
*
* 行为(对应产品需求):
* - 收到教师通知:变形为"新消息"胶囊;点击展开显示详情
* - 上课时间段:非紧急通知不弹出(自动隐藏),进入待发队列
* - 下课后:自动在桌面中上方弹出队列中的通知详情
* - 紧急通知:无论是否在上课,立刻展开显示详情,无需点击
*/
const SIZES: Record<IslandMode, { width: number; height: number }> = {
hidden: { width: 268, height: 44 },
pill: { width: 268, height: 44 },
expanded: { width: 404, height: 316 },
};
const URGENT_SIZE = { width: 424, height: 344 };
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
const TIMEOUTS = {
pill: 15_000,
afterClassExpanded: 14_000,
afterClassPill: 25_000,
expanded: 20_000,
urgent: 45_000,
};
export interface IslandPushContext {
inClass?: boolean;
currentPeriodEnd?: string | null;
week?: number;
}
export interface IslandClassStatePayload {
inClass: boolean;
currentPeriodEnd?: string | null;
week?: number;
}
type IslandActionName = 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app';
interface IslandActionPayload {
action: IslandActionName;
id?: string;
}
class IslandController {
private win: BrowserWindow | null = null;
private rendererUrl: string | null = null;
private ready = false;
private pendingState: IslandState | null = null;
private collapseTimer: ReturnType<typeof setTimeout> | null = null;
/** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */
private boundsToken = 0;
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
private fadeToken = 0;
private state: IslandState = {
mode: 'hidden',
active: null,
queued: [],
inClass: false,
currentPeriodEnd: null,
reason: null,
updatedAt: Date.now(),
};
/** 灵动岛展开时点击"打开应用"交给外部处理 */
private openAppHandler: (() => void) | null = null;
setOpenAppHandler(handler: () => void): void {
this.openAppHandler = handler;
}
async init(rendererUrl: string | null): Promise<void> {
this.rendererUrl = rendererUrl;
if (this.win && !this.win.isDestroyed()) return;
const win = new BrowserWindow({
...this.computeBounds(SIZES.hidden),
show: false,
frame: false,
transparent: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
skipTaskbar: true,
hasShadow: false,
// 不抢焦点:通知不应该打断学生正在做的事(键盘焦点仍留在原应用)
focusable: false,
alwaysOnTop: true,
type: 'toolbar',
webPreferences: {
preload: path.join(__dirname, '../preload/island.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
backgroundThrottling: false,
},
});
win.setAlwaysOnTop(true, 'screen-saver');
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
win.setIgnoreMouseEvents(false);
win.on('closed', () => {
this.win = null;
this.ready = false;
});
this.win = win;
// 开发/冒烟时把灵动岛渲染进程的日志转发到主进程,便于排查"窗口空白"之类问题
win.webContents.on('console-message', (...args) => {
const first = args[0];
const isEventObject = first && typeof first === 'object' && 'message' in first;
const level = isEventObject ? first.level : args[1];
const message = isEventObject ? first.message : args[2];
const isProblem = level === 'error' || level === 'warning' || level === 2 || level === 3;
if (isProblem || process.env.ELECTRON_SMOKE_TEST === '1') {
logger.info(`[island-renderer] ${String(message)}`);
}
});
if (rendererUrl) {
await win.loadURL(rendererUrl);
} else {
await win.loadFile(path.join(__dirname, '../renderer/island.html'));
}
this.ready = true;
if (this.pendingState) {
this.emit(this.pendingState);
this.pendingState = null;
}
// 首次进入时同步一次当前状态,保证渲染进程不会停在初始默认值
this.emit();
logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)');
}
getWindow(): BrowserWindow | null {
return this.win;
}
getState(): IslandState {
return { ...this.state, queued: [...this.state.queued] };
}
isReady(): boolean {
return this.ready && Boolean(this.win) && !this.win?.isDestroyed();
}
/* ------------------------------------------------------------ 对外行为 */
/**
* 收到一条通知。
* - 紧急:立刻展开(无视上课时段)
* - 上课中:进队列并保持隐藏(下课后由 setClassState 触发弹出)
* - 其它:显示"新消息"胶囊,等待点击展开
*/
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
const inClass = context.inClass ?? this.state.inClass;
this.state.inClass = inClass;
if (context.currentPeriodEnd !== undefined)
this.state.currentPeriodEnd = context.currentPeriodEnd ?? null;
if (notification.priority === 'URGENT') {
// 紧急插播:如果正在展示别的通知,把它放回队列
if (this.state.active && this.state.active.id !== notification.id) {
this.state.queued.push(this.state.active);
}
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
this.activate(notification, 'urgent', 'expanded');
logger.info(`灵动岛紧急插播:${notification.title}`);
return;
}
if (inClass) {
// 上课时段:自动隐藏,暂存待下课后弹出
this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification];
if (this.state.mode !== 'hidden') {
this.setState({ mode: 'hidden', reason: null });
}
this.state.updatedAt = Date.now();
this.emit();
logger.info(`灵动岛暂存通知(上课中,下课后弹出):${notification.title}`);
return;
}
if (this.state.mode === 'expanded' && this.state.active) {
// 已经展开时,把当前这条挤入队列并显示新的一条
this.state.queued.push(this.state.active);
}
this.state.queued = [...this.state.queued.filter((item) => item.id !== notification.id), notification];
this.activate(notification, 'new', 'pill');
}
/** 上课状态变化:进入上课隐藏;下课则自动弹出队列 */
setClassState(payload: IslandClassStatePayload): void {
const wasInClass = this.state.inClass;
this.state.inClass = payload.inClass;
this.state.currentPeriodEnd = payload.currentPeriodEnd ?? null;
if (payload.inClass) {
if (!wasInClass) logger.info('灵动岛:进入上课时间段,自动隐藏');
if (this.state.mode !== 'hidden') {
// 上课期间自动隐藏(紧急通知也遵循,避免持续遮挡课堂投影)
this.setState({ mode: 'hidden', reason: null });
} else {
this.state.updatedAt = Date.now();
this.emit();
}
return;
}
if (wasInClass && !payload.inClass) {
logger.info('灵动岛:下课,弹出上课期间暂存的通知');
this.flushQueueAfterClass();
return;
}
this.state.updatedAt = Date.now();
this.emit();
}
/** 下课:弹出队列中最新的一条(展开显示详情),其余保留为"还有 N 条" */
flushQueueAfterClass(): void {
const next = this.state.queued[this.state.queued.length - 1];
if (!next) {
this.setState({ mode: 'hidden', reason: null });
return;
}
const urgent = this.state.queued.find((item) => item.priority === 'URGENT');
const target = urgent ?? next;
this.activate(target, 'after-class', 'expanded');
}
handleAction(payload: IslandActionPayload): void {
switch (payload.action) {
case 'expand':
if (this.state.active) {
this.setState({ mode: 'expanded' });
this.scheduleCollapse(
this.state.active.priority === 'URGENT' ? TIMEOUTS.urgent : TIMEOUTS.expanded,
);
}
break;
case 'collapse': {
const next = this.state.queued[this.state.queued.length - 1];
if (next) {
this.setState({ mode: 'pill', reason: this.state.reason });
this.scheduleCollapse(TIMEOUTS.pill);
} else {
this.hide();
}
break;
}
case 'dismiss':
this.dismissActive();
break;
case 'mark-read': {
const queue = this.state.queued.filter((item) => item.id !== payload.id);
this.state.queued = queue;
if (this.state.active?.id === payload.id) this.state.active = null;
this.state.updatedAt = Date.now();
if (this.state.mode === 'hidden') this.emit();
else this.dismissActive();
break;
}
case 'open-app':
this.openAppHandler?.();
break;
default:
break;
}
}
hide(): void {
this.clearTimers();
if (!this.win || this.win.isDestroyed()) return;
this.fadeOut();
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
}
destroy(): void {
this.clearTimers();
if (this.win && !this.win.isDestroyed()) this.win.destroy();
this.win = null;
}
/* ------------------------------------------------------------ 内部实现 */
private dismissActive(): void {
this.clearTimers();
if (this.state.active) {
this.state.queued = this.state.queued.filter((item) => item.id !== this.state.active?.id);
}
this.state.active = null;
const next = this.state.queued[this.state.queued.length - 1];
if (next) {
// 还有未看的通知:退成胶囊态提示"还有 N 条"
this.state.active = next;
this.setState({ mode: 'pill', reason: 'after-class' });
this.scheduleCollapse(TIMEOUTS.afterClassPill);
return;
}
this.hide();
}
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
this.clearTimers();
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
this.state.queued = this.state.queued.filter((item) => item.id !== notification.id);
this.state.active = notification;
this.state.updatedAt = Date.now();
this.setState({ mode, reason });
if (notification.priority === 'URGENT') {
this.scheduleCollapse(TIMEOUTS.urgent);
} else if (reason === 'after-class') {
this.scheduleCollapse(TIMEOUTS.afterClassExpanded);
} else if (mode === 'pill') {
this.scheduleCollapse(TIMEOUTS.pill);
} else {
this.scheduleCollapse(TIMEOUTS.expanded);
}
}
private scheduleCollapse(delayMs: number): void {
this.clearTimers();
this.collapseTimer = setTimeout(() => {
this.collapseTimer = null;
if (this.state.mode === 'expanded') {
const hasMore = this.state.queued.length > 1;
if (hasMore) {
this.setState({ mode: 'pill' });
this.scheduleCollapse(TIMEOUTS.afterClassPill);
} else {
this.hide();
}
return;
}
this.hide();
}, delayMs);
}
private clearTimers(): void {
if (this.collapseTimer) {
clearTimeout(this.collapseTimer);
this.collapseTimer = null;
}
}
private computeBounds(size: { width: number; height: number }): {
x: number;
y: number;
width: number;
height: number;
} {
const area = screen.getPrimaryDisplay().workArea;
return {
x: Math.round(area.x + (area.width - size.width) / 2),
y: area.y + 8,
width: size.width,
height: size.height,
};
}
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
private setState(patch: Partial<IslandState>, options: { skipWindow?: boolean } = {}): void {
this.state = { ...this.state, ...patch, updatedAt: Date.now() };
this.emit();
if (!options.skipWindow) this.syncWindow();
}
private emit(state?: IslandState): void {
const payload = state ?? this.getState();
if (!this.win || this.win.isDestroyed()) {
this.pendingState = payload;
return;
}
if (!this.ready) {
this.pendingState = payload;
return;
}
this.win.webContents.send('island:state', payload);
}
private syncWindow(): void {
if (!this.win || this.win.isDestroyed()) return;
if (this.state.mode === 'hidden') {
this.fadeOut();
return;
}
const size =
this.state.mode === 'expanded'
? this.state.active?.priority === 'URGENT'
? URGENT_SIZE
: SIZES.expanded
: SIZES.pill;
if (!this.win.isVisible()) {
this.win.setOpacity(0);
this.win.showInactive();
this.animateBounds(size);
this.fadeIn();
return;
}
this.animateBounds(size);
}
private fadeIn(): void {
if (!this.win || this.win.isDestroyed()) return;
const token = ++this.fadeToken;
let opacity = this.win.getOpacity();
const step = (): void => {
if (!this.win || this.win.isDestroyed() || token !== this.fadeToken) return;
opacity = Math.min(1, opacity + 0.16);
this.win.setOpacity(opacity);
if (opacity < 1) setTimeout(step, 16);
};
step();
}
private fadeOut(): void {
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return;
const token = ++this.fadeToken;
let opacity = this.win.getOpacity();
const step = (): void => {
if (!this.win || this.win.isDestroyed() || token !== this.fadeToken) return;
opacity = Math.max(0, opacity - 0.2);
this.win.setOpacity(opacity);
if (opacity > 0.02) {
setTimeout(step, 16);
return;
}
this.win.hide();
this.win.setOpacity(1);
};
step();
}
/** 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果 */
private animateBounds(size: { width: number; height: number }): void {
if (!this.win || this.win.isDestroyed()) return;
const from = this.win.getBounds();
const to = this.computeBounds(size);
if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return;
const token = ++this.boundsToken;
const duration = 260;
const startedAt = Date.now();
const step = (): void => {
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
const progress = Math.min(1, (Date.now() - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
const bounds = {
x: Math.round(from.x + (to.x - from.x) * eased),
y: Math.round(from.y + (to.y - from.y) * eased),
width: Math.round(from.width + (to.width - from.width) * eased),
height: Math.round(from.height + (to.height - from.height) * eased),
};
this.win.setBounds(bounds);
if (progress < 1) setTimeout(step, 12);
};
step();
}
}
export const island = new IslandController();
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
export function registerIslandIpc(): void {
ipcMain.on(
'island:push',
(_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => {
if (!payload?.notification) return;
island.pushNotification(payload.notification, payload.context ?? {});
},
);
ipcMain.on('island:class-state', (_event, payload: IslandClassStatePayload) => {
if (!payload) return;
island.setClassState(payload);
});
ipcMain.handle('island:get-state', () => island.getState());
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
if (!payload?.action) return;
island.handleAction(payload);
});
}
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE };
@@ -0,0 +1,25 @@
/**
* 主进程日志(控制台输出)。
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
* - 主窗口右上角的连接状态 / 灵动岛状态
* 对外可见,避免"出了问题却看不到日志"。
*/
function timestamp(): string {
return new Date().toISOString();
}
export const logger = {
debug(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
},
info(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] INFO ${message}`, ...args);
},
warn(message: string, ...args: unknown[]): void {
console.warn(`[${timestamp()}] WARN ${message}`, ...args);
},
error(message: string, ...args: unknown[]): void {
console.error(`[${timestamp()}] ERROR ${message}`, ...args);
},
};
+176
View File
@@ -2,7 +2,9 @@ import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
import type { BrowserWindow } from 'electron';
import type { IslandNotification } from '@classhelper/shared';
import { getDiagnostics } from './ipc.js';
import { island } from './island.js';
interface SmokeResult {
name: string;
@@ -10,6 +12,177 @@ interface SmokeResult {
detail: string;
}
type Recorder = (name: string, ok: boolean, detail?: string) => void;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 灵动岛状态截图留档(便于人工复核外观与动画) */
async function captureIsland(name: string): Promise<void> {
const dir = process.env.ISLAND_SHOTS_DIR;
const win = island.getWindow();
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return;
try {
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
await win.webContents.executeJavaScript(
`document.documentElement.style.background = '#1f2733'; document.body.style.background = '#1f2733'; true`,
);
await sleep(120);
const image = await win.webContents.capturePage();
await win.webContents.executeJavaScript(
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${name}.png`), image.toPNG());
console.log(`[SMOKE] 灵动岛截图:${path.join(dir, `${name}.png`)}`);
} catch (error) {
console.warn('[SMOKE] 灵动岛截图失败', error);
}
}
/** 打印灵动岛渲染进程的真实 DOM 结构(排查空白窗口) */
async function dumpIslandDom(_label: string): Promise<string> {
const win = island.getWindow();
if (!win || win.isDestroyed()) return 'no-window';
try {
return await win.webContents.executeJavaScript(
`(() => {
const app = document.querySelector('#app');
const card = document.querySelector('.island-card');
return JSON.stringify({
appChildren: app ? app.children.length : -1,
cardClass: card ? card.className : null,
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 60),
});
})()`,
);
} catch (error) {
return `dump-error: ${(error as Error).message}`;
}
}
function makeNotification(
id: string,
priority: IslandNotification['priority'],
title: string,
): IslandNotification {
return {
id,
title,
content:
'明天下午的数学课改为在实验楼 B205 上课,请同学们提前 10 分钟到达,并携带上次发的练习册。如有疑问请联系课代表。',
priority,
createdAt: new Date().toISOString(),
courseName: '数学',
teacherName: '张老师',
};
}
/**
* 灵动岛行为验证(对应产品需求):
* 1. 上课时间段收到普通通知 → 自动隐藏并暂存
* 2. 下课后 → 自动在桌面中上方弹出详情
* 3. 上课时间段收到紧急通知 → 立即展开显示详情(无需点击)
* 4. 非上课时段收到普通通知 → 变成"新消息"胶囊,点击后展开
*/
async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promise<void> {
record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`);
if (!island.isReady()) return;
const islandWindow = island.getWindow();
// 1) 上课期间:普通通知必须保持隐藏并进入队列
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
island.pushNotification(makeNotification('smoke-normal-in-class', 'NORMAL', '上课期间的普通通知'), {
inClass: true,
});
await sleep(500);
const hiddenState = island.getState();
record(
'上课期间普通通知自动隐藏(暂存待下课弹出)',
hiddenState.mode === 'hidden' && hiddenState.queued.length === 1,
`mode=${hiddenState.mode} queued=${hiddenState.queued.length}`,
);
console.log(`[SMOKE] 隐藏态 DOM:${await dumpIslandDom('hidden')}`);
// 2) 下课:自动弹出暂存通知的详情
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(900);
const afterClassState = island.getState();
record(
'下课后自动弹出通知详情',
afterClassState.mode === 'expanded' && afterClassState.active?.id === 'smoke-normal-in-class',
`mode=${afterClassState.mode} active=${afterClassState.active?.id ?? '-'} reason=${afterClassState.reason ?? '-'}`,
);
await captureIsland('island-2-after-class');
// 3) 上课期间紧急通知:立即展开,无需点击
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(300);
island.pushNotification(makeNotification('smoke-urgent-in-class', 'URGENT', '上课期间的紧急通知'), {
inClass: true,
});
await sleep(900);
const urgentState = island.getState();
record(
'上课期间紧急通知立即展开显示详情(无需点击)',
urgentState.mode === 'expanded' &&
urgentState.active?.id === 'smoke-urgent-in-class' &&
urgentState.reason === 'urgent',
`mode=${urgentState.mode} active=${urgentState.active?.id ?? '-'} reason=${urgentState.reason ?? '-'}`,
);
await captureIsland('island-3-urgent');
// 4) 非上课时段普通通知 → 胶囊态;点击后展开
island.handleAction({ action: 'dismiss' });
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(300);
island.pushNotification(makeNotification('smoke-normal-free', 'NORMAL', '课间收到的普通通知'), {
inClass: false,
});
await sleep(700);
const pillState = island.getState();
await captureIsland('island-4-pill');
console.log(`[SMOKE] 胶囊态 DOM:${await dumpIslandDom('pill')}`);
const clicked = await islandWindow?.webContents.executeJavaScript(
`(() => {
const pill = document.querySelector('.island-card.pill:not(.hidden-placeholder)');
if (!pill) return false;
pill.click();
return true;
})()`,
);
await sleep(800);
const expandedState = island.getState();
record(
'普通通知先变"新消息"胶囊,点击后展开详情',
pillState.mode === 'pill' && clicked === true && expandedState.mode === 'expanded',
`pillMode=${pillState.mode} clicked=${clicked} expandedMode=${expandedState.mode}`,
);
await captureIsland('island-5-clicked');
// 5) 灵动岛 DOM 结构自检(确保渲染进程真的画出卡片而不是空白窗口)
const domInfo = await islandWindow?.webContents.executeJavaScript(
`(() => ({
hasCard: Boolean(document.querySelector('.island-card')),
title: document.querySelector('.title')?.textContent ?? '',
hasActions: Boolean(document.querySelector('.solid-btn')),
}))()`,
);
record(
'灵动岛渲染进程正常绘制(卡片/标题/操作按钮)',
Boolean(domInfo?.hasCard && domInfo?.title && domInfo?.hasActions),
`title=${domInfo?.title ?? '-'} actions=${domInfo?.hasActions ?? false}`,
);
// 收尾:隐藏灵动岛,避免影响后续用例
island.handleAction({ action: 'dismiss' });
await sleep(300);
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
}
/**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
@@ -135,6 +308,9 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
);
record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
// ---------------------------------------------------------------- 灵动岛
await runIslandChecks(record, results);
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
const online = await win.webContents.executeJavaScript(
+16 -1
View File
@@ -1,6 +1,13 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../main/ipc.js';
import type { DesktopAppInfo, DesktopBridge, DesktopStoredConfig } from '../types/desktop.js';
import type {
DesktopAppInfo,
DesktopBridge,
DesktopStoredConfig,
IslandClassStatePayload,
IslandPushContext,
} from '../types/desktop.js';
import type { IslandNotification } from '@classhelper/shared';
/**
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
@@ -14,6 +21,14 @@ const bridge: DesktopBridge = {
clearConfig: (): Promise<DesktopStoredConfig> => ipcRenderer.invoke(IPC_CHANNELS.clearConfig),
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url),
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
ipcRenderer.send('island:push', payload);
},
islandSetClassState: (payload: IslandClassStatePayload): void => {
ipcRenderer.send('island:class-state', payload);
},
islandGetState: () => ipcRenderer.invoke('island:get-state'),
};
contextBridge.exposeInMainWorld('desktop', bridge);
@@ -0,0 +1,24 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { IslandState } from '@classhelper/shared';
/**
* 灵动岛窗口的预加载脚本。
* 只暴露「订阅状态」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
*/
export interface IslandBridge {
onState(handler: (state: IslandState) => void): void;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
getState(): Promise<IslandState>;
}
const bridge: IslandBridge = {
onState: (handler) => {
ipcRenderer.on('island:state', (_event, state: IslandState) => handler(state));
},
sendAction: (action, id) => {
ipcRenderer.send('island:action', { action, id });
},
getState: () => ipcRenderer.invoke('island:get-state'),
};
contextBridge.exposeInMainWorld('island', bridge);
+9 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { startIslandBridge, stopIslandBridge } from './island/bridge.js';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useRealtimeStore } from './stores/realtime.js';
@@ -29,12 +30,19 @@ onMounted(async () => {
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
if (auth.token) startIslandBridge();
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
} finally {
booting.value = false;
}
});
onUnmounted(() => {
stopIslandBridge();
});
</script>
<template>
@@ -0,0 +1,111 @@
import {
SOCKET_EVENTS,
resolveClassStatus,
type ClassPeriod,
type NotificationDto,
type ScheduleDto,
} from '@classhelper/shared';
import { scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
/**
* 灵动岛桥接(渲染进程侧)。
*
* 职责:把"当前是否在上课时间段"这件事算出来并同步给主进程 ——
* 主进程据此决定通知是"立即弹出"还是"上课期间隐藏、下课后自动弹出"。
*
* 判定完全基于本地缓存的课表(离线也能算),并每 10 秒复算一次,
* 因此下课后最多 10 秒内会自动弹出暂存的通知。
*/
const TICK_INTERVAL_MS = 10_000;
const REFRESH_INTERVAL_MS = 5 * 60_000;
let schedules: ScheduleDto[] = [];
let currentPeriod: ClassPeriod | null = null;
let inClass = false;
let tickTimer: ReturnType<typeof setInterval> | null = null;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
/** 供通知投递时读取:当前是否上课 + 本节课结束时间 */
export function getIslandClassContext(): { inClass: boolean; currentPeriodEnd: string | null } {
return { inClass, currentPeriodEnd: currentPeriod?.endTime ?? null };
}
export function getIslandSchedules(): ScheduleDto[] {
return schedules;
}
/** 主动刷新课表(课表变更事件触发) */
export async function refreshIslandSchedules(): Promise<void> {
const appStore = useAppStore();
const auth = useAuthStore();
if (!auth.token) return;
const week = appStore.currentWeek;
const result = await fetchWithCache<ScheduleDto[]>(
'schedules',
`week:${week}`,
() => scheduleApi.list({ classId: auth.classId ?? undefined, week }),
[],
);
schedules = result.data;
recomputeClassState(true);
}
function recomputeClassState(force = false): void {
const appStore = useAppStore();
const status = resolveClassStatus(schedules, new Date(), appStore.currentWeek);
const nextInClass = status.inClass;
currentPeriod = status.current;
if (!force && nextInClass === inClass) return;
inClass = nextInClass;
window.desktop?.islandSetClassState({
inClass,
currentPeriodEnd: currentPeriod?.endTime ?? null,
week: appStore.currentWeek,
});
}
/** 把校园通知投递到灵动岛(由实时通道收到 notification:new 时调用) */
export function pushNotificationToIsland(notification: NotificationDto): void {
window.desktop?.islandPush({
notification: {
id: notification.id,
title: notification.title,
content: notification.content,
priority: notification.priority,
createdAt: notification.createdAt,
courseName: currentPeriod?.courseName ?? null,
teacherName: notification.creator?.name ?? null,
},
context: getIslandClassContext(),
});
}
export function startIslandBridge(): void {
if (tickTimer) return;
void refreshIslandSchedules().catch(() => undefined);
// 课表变更(教师改课)立即复算,避免"下课后不弹出"或"上课没隐藏"
useRealtimeStore().on(SOCKET_EVENTS.scheduleUpdated, () => {
void refreshIslandSchedules().catch(() => undefined);
});
tickTimer = setInterval(() => recomputeClassState(), TICK_INTERVAL_MS);
refreshTimer = setInterval(() => {
void refreshIslandSchedules().catch(() => undefined);
}, REFRESH_INTERVAL_MS);
}
export function stopIslandBridge(): void {
if (tickTimer) clearInterval(tickTimer);
if (refreshTimer) clearInterval(refreshTimer);
tickTimer = null;
refreshTimer = null;
}
@@ -11,6 +11,7 @@ import {
type ServerToClientEvents,
} from '@classhelper/shared';
import { socketUrlOf } from '../config.js';
import { pushNotificationToIsland } from '../island/bridge.js';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
@@ -70,6 +71,8 @@ export const useRealtimeStore = defineStore('realtime', () => {
duration: 5000,
showClose: true,
});
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
pushNotificationToIsland(payload);
dispatch(SOCKET_EVENTS.notificationNew, payload);
});
+34 -1
View File
@@ -1,7 +1,8 @@
/**
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
* 渲染进程只依赖这个最小 API,不接触 Node.js。
* 渲染进程只依赖这些最小 API,不接触 Node.js。
*/
import type { IslandNotification, IslandState } from '@classhelper/shared';
export interface DesktopStoredConfig {
/** 后端服务地址,例如 http://127.0.0.1:4000 */
@@ -23,6 +24,21 @@ export interface DesktopAppInfo {
smokeTest: boolean;
}
/** 投递通知到灵动岛时附带的上下文 */
export interface IslandPushContext {
/** 当前是否处于上课时间段(上课期间非紧急通知会被暂存,下课后自动弹出) */
inClass?: boolean;
/** 当前这节课的结束时间(HH:mm) */
currentPeriodEnd?: string | null;
week?: number;
}
export interface IslandClassStatePayload {
inClass: boolean;
currentPeriodEnd?: string | null;
week?: number;
}
export interface DesktopBridge {
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发),同步可读 */
smokeTest: boolean;
@@ -31,12 +47,29 @@ export interface DesktopBridge {
clearConfig(): Promise<DesktopStoredConfig>;
getAppInfo(): Promise<DesktopAppInfo>;
openExternal(url: string): Promise<boolean>;
/* 灵动岛 */
/** 把一条通知投递到灵动岛 */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
islandSetClassState(payload: IslandClassStatePayload): void;
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
islandGetState(): Promise<IslandState>;
}
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
export interface IslandRendererBridge {
onState(handler: (state: IslandState) => void): void;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
getState(): Promise<IslandState>;
}
declare global {
interface Window {
/** 仅在 Electron 中注入;浏览器里为 undefined(渲染进程做了降级处理) */
desktop?: DesktopBridge;
/** 灵动岛窗口专用桥接 */
island?: IslandRendererBridge;
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
__classhelperSmoke__?: {
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
+2
View File
@@ -18,6 +18,8 @@
"src/renderer/**/*.ts",
"src/renderer/**/*.d.ts",
"src/renderer/**/*.vue",
"src/island/**/*.ts",
"src/island/**/*.vue",
"src/types/**/*.d.ts"
]
}
+8
View File
@@ -23,6 +23,14 @@ export default defineConfig({
emptyOutDir: true,
sourcemap: false,
chunkSizeWarningLimit: 2000,
rollupOptions: {
input: {
// 主窗口
index: fileURLToPath(new URL('./index.html', import.meta.url)),
// 灵动岛(独立的置顶透明小窗口)
island: fileURLToPath(new URL('./island.html', import.meta.url)),
},
},
},
server: {
host: '127.0.0.1',
+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);
+68
View File
@@ -247,6 +247,11 @@ export interface CreateNotificationRequest {
title: string;
content: string;
priority?: NotificationPriority;
/**
* 上课时间段内发布"紧急"通知时必须显式置为 true(表示教师已确认会干扰上课)。
* 否则服务端返回 409 URGENT_DURING_CLASS,由前端弹出全屏二次确认。
*/
confirmDuringClass?: boolean;
}
export interface NotificationQueryParams {
@@ -300,6 +305,69 @@ export interface GradeStats {
distribution: Array<{ level: GradeLevel; count: number }>;
}
/* ------------------------------------------------------------------ 上课时段(灵动岛 / 紧急通知确认共用) */
/** 一节课的时段信息 */
export interface ClassPeriod {
scheduleId: string;
courseId: string;
courseName: string;
/** HH:mm */
startTime: string;
/** HH:mm */
endTime: string;
location: string | null;
/** 1=周一 ... 7=周日 */
dayOfWeek: number;
}
/** 班级当前上课状态 */
export interface ClassStatusDto {
classId: string;
/** 当前是否处于上课时间段 */
inClass: boolean;
/** 正在上的这节课(inClass=true 时非空) */
current: ClassPeriod | null;
/** 下一节课(用于"下课后自动弹出"的提示与倒计时) */
next: ClassPeriod | null;
/** 服务器判定所用的时间(客户端可据此校正本地时钟偏差) */
serverTime: string;
/** 判定所用的教学周 */
week: number;
}
/* ------------------------------------------------------------------ 灵动岛 */
/** 灵动岛显示状态 */
export type IslandMode = 'hidden' | 'pill' | 'expanded';
/** 投递到灵动岛的通知载荷(服务端 NotificationDto 的精简版) */
export interface IslandNotification {
id: string;
title: string;
content: string;
priority: NotificationPriority;
createdAt: string;
courseName?: string | null;
teacherName?: string | null;
}
/** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */
export interface IslandState {
mode: IslandMode;
/** 正在展示的通知(展开态或胶囊态) */
active: IslandNotification | null;
/** 上课期间被暂存、待下课后弹出的通知 */
queued: IslandNotification[];
/** 当前是否处于上课时间段 */
inClass: boolean;
/** 当前课的结束时间(HH:mm),用于"下课后自动弹出" */
currentPeriodEnd: string | null;
/** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 */
reason: 'new' | 'after-class' | 'urgent' | null;
updatedAt: number;
}
/* ------------------------------------------------------------------ 仪表盘 */
export interface DashboardSummary {
+100 -1
View File
@@ -1,5 +1,5 @@
import { WEEKDAY_LABELS, WEEKDAYS } from './constants.js';
import type { GradeLevel, ScheduleDto, ScheduleWeekView } from './types.js';
import type { ClassPeriod, GradeLevel, ScheduleDto, ScheduleWeekView } from './types.js';
/* ------------------------------------------------------------------ 日期时间 */
@@ -207,3 +207,102 @@ export function truncate(text: string | null | undefined, max = 60): string {
export function isValidTimeString(value: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
}
/* ------------------------------------------------------------------ 上课时段判定 */
/** HH:mm -> 分钟数(非法输入返回 -1) */
export function timeToMinutes(value: string): number {
const [hour, minute] = value.split(':').map(Number);
if (hour === undefined || minute === undefined || Number.isNaN(hour) || Number.isNaN(minute)) return -1;
return hour * 60 + minute;
}
const MS_PER_DAY = 86_400_000;
export const MAX_TERM_WEEK = 30;
/**
* 依据学期起始日(第 1 教学周的周一)计算当前教学周。
* 服务端与三端共用同一份实现,避免"周次"口径不一致。
*/
export function resolveCurrentWeek(
termStartDate: string,
now: Date = new Date(),
maxWeek: number = MAX_TERM_WEEK,
): number {
if (!termStartDate) return 1;
const start = toDate(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 clamp(week, 1, maxWeek);
}
/** 把课表条目转换成时段信息 */
export function toClassPeriod(schedule: ScheduleDto): ClassPeriod {
return {
scheduleId: schedule.id,
courseId: schedule.courseId,
courseName: schedule.course?.name ?? '课程',
startTime: schedule.startTime,
endTime: schedule.endTime,
location: schedule.location,
dayOfWeek: schedule.dayOfWeek,
};
}
/**
* 判断某一时刻是否处于上课时间段,并给出正在上的课与下一节课。
*
* @param schedules 该班级的课表(可含全部周次)
* @param now 判定时刻
* @param week 当前教学周;传 0 / 省略表示不按周次过滤(仅按星期与时间)
*/
export function resolveClassStatus(
schedules: ScheduleDto[],
now: Date = new Date(),
week?: number,
): { inClass: boolean; current: ClassPeriod | null; next: ClassPeriod | null } {
const weekday = weekdayOf(now);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const weekFiltered =
week && week > 0 ? schedules.filter((item) => isScheduleActiveInWeek(item, week)) : schedules;
const today = weekFiltered.filter((item) => item.dayOfWeek === weekday);
const current = today
.filter((item) => {
const start = timeToMinutes(item.startTime);
const end = timeToMinutes(item.endTime);
return start >= 0 && end > start && nowMinutes >= start && nowMinutes < end;
})
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime))[0];
const next = today
.filter((item) => timeToMinutes(item.startTime) > nowMinutes)
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime))[0];
return {
inClass: Boolean(current),
current: current ? toClassPeriod(current) : null,
next: next ? toClassPeriod(next) : null,
};
}
/** 时段的可读文案,例如 "08:00-08:45 · 数学 · 教学楼 A301" */
export function formatClassPeriod(period: ClassPeriod | null): string {
if (!period) return '';
const location = period.location ? ` · ${period.location}` : '';
return `${period.startTime}-${period.endTime} · ${period.courseName}${location}`;
}
/** 距离某个 HH:mm 还有多少分钟(已过则为负) */
export function minutesUntil(time: string, now: Date = new Date()): number {
const target = timeToMinutes(time);
if (target < 0) return 0;
return target - (now.getHours() * 60 + now.getMinutes());
}
+4
View File
@@ -2,6 +2,7 @@ import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type ClassStatusDto,
type CourseDto,
type CreateClassRequest,
type CreateCourseRequest,
@@ -76,6 +77,9 @@ export const scheduleApi = {
api.get(API_PATHS.schedules, params),
grid: (params: { classId?: string; week?: number }): Promise<ScheduleWeekView> =>
api.get(`${API_PATHS.schedules}/grid`, params),
/** 班级当前上课状态:上课时段发布紧急通知时必须先二次确认 */
classStatus: (classId: string): Promise<ClassStatusDto> =>
api.get(`${API_PATHS.schedules}/current`, { classId }),
create: (payload: CreateScheduleRequest): Promise<ScheduleDto> => api.post(API_PATHS.schedules, payload),
update: (id: string, payload: Partial<CreateScheduleRequest>): Promise<ScheduleDto> =>
api.patch(`${API_PATHS.schedules}/${id}`, payload),
@@ -0,0 +1,291 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import { formatClassPeriod, type ClassPeriod } from '@classhelper/shared';
/**
* 上课时段发布紧急通知的全屏二次确认。
*
* 设计要点:
* - 全屏遮罩 + 强警示配色,明确告知"会干扰上课"
* - 确认按钮有 3 秒倒计时保护,倒计时结束前不可点击(避免误触连点)
* - 倒计时以圆环进度可视化,并给出每秒反馈
*/
const props = defineProps<{
visible: boolean;
period: ClassPeriod | null;
pendingTitle: string;
}>();
const emit = defineEmits<{
(event: 'cancel'): void;
(event: 'confirm'): void;
}>();
const COUNTDOWN_SECONDS = 3;
const remaining = ref(COUNTDOWN_SECONDS);
let timer: ReturnType<typeof setInterval> | null = null;
const periodText = computed(() => formatClassPeriod(props.period));
const canConfirm = computed(() => remaining.value <= 0);
const progress = computed(() => ((COUNTDOWN_SECONDS - remaining.value) / COUNTDOWN_SECONDS) * 100);
function stopTimer(): void {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function startCountdown(): void {
stopTimer();
remaining.value = COUNTDOWN_SECONDS;
timer = setInterval(() => {
remaining.value -= 1;
if (remaining.value <= 0) {
remaining.value = 0;
stopTimer();
}
}, 1000);
}
watch(
() => props.visible,
(visible) => {
if (visible) startCountdown();
else stopTimer();
},
);
onUnmounted(stopTimer);
function handleCancel(): void {
stopTimer();
emit('cancel');
}
function handleConfirm(): void {
if (!canConfirm.value) return;
stopTimer();
emit('confirm');
}
</script>
<template>
<Teleport to="body">
<Transition name="urgent-fade">
<div v-if="visible" class="urgent-mask" role="alertdialog" aria-modal="true">
<div class="urgent-card">
<div class="urgent-icon">
<el-icon :size="34"><WarningFilled /></el-icon>
</div>
<h2 class="urgent-title">现在为上课时间段</h2>
<p class="urgent-subtitle">
如果发布
<strong>紧急</strong>
通知,会干扰正在进行的课堂,请再次确认。
</p>
<div v-if="period" class="urgent-period">
<span class="period-label">正在上课</span>
<span class="period-value">{{ periodText }}</span>
</div>
<div class="urgent-preview">
<span class="preview-label">即将发布</span>
<span class="preview-title">{{ pendingTitle || '(无标题)' }}</span>
</div>
<div class="urgent-actions">
<el-button size="large" @click="handleCancel">取消发布</el-button>
<el-button size="large" type="danger" :disabled="!canConfirm" @click="handleConfirm">
<template v-if="canConfirm">确认发布紧急通知</template>
<template v-else>请等待 {{ remaining }} 秒…</template>
</el-button>
</div>
<div class="urgent-progress">
<div v-if="!canConfirm" class="progress-ring">
<el-progress
type="circle"
:percentage="progress"
:width="46"
:stroke-width="4"
:show-text="false"
color="#f56c6c"
/>
<span class="ring-text">{{ remaining }}</span>
</div>
<span class="progress-hint">
{{
canConfirm
? '倒计时结束,请谨慎确认是否真的需要立即打扰课堂'
: '正在为你预留 3 秒冷静时间,避免误触打扰课堂'
}}
</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.urgent-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 12, 12, 0.62);
backdrop-filter: blur(6px);
}
.urgent-card {
width: 520px;
max-width: calc(100vw - 48px);
padding: 28px 30px 22px;
border-radius: 16px;
background: #fff;
border-top: 4px solid #f56c6c;
box-shadow: 0 24px 60px rgba(120, 20, 20, 0.35);
text-align: center;
}
.urgent-icon {
width: 64px;
height: 64px;
margin: 0 auto 12px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #f56c6c;
background: #fef0f0;
animation: urgent-pulse 1.4s ease-in-out infinite;
}
@keyframes urgent-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.45);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 14px rgba(245, 108, 108, 0);
transform: scale(1.04);
}
}
.urgent-title {
margin: 0 0 8px;
font-size: 22px;
color: #303133;
}
.urgent-subtitle {
margin: 0 0 18px;
font-size: 14px;
line-height: 1.7;
color: #606266;
}
.urgent-subtitle strong {
color: #f56c6c;
}
.urgent-period,
.urgent-preview {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
margin-bottom: 10px;
text-align: left;
font-size: 13px;
}
.urgent-period {
background: #fef0f0;
}
.urgent-preview {
background: #f5f7fa;
}
.period-label,
.preview-label {
flex: 0 0 64px;
color: #909399;
}
.period-value {
color: #f56c6c;
font-weight: 600;
}
.preview-title {
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.urgent-actions {
display: flex;
gap: 12px;
justify-content: center;
margin: 18px 0 12px;
}
.urgent-progress {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 52px;
}
.progress-ring {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.ring-text {
position: absolute;
font-size: 15px;
font-weight: 700;
color: #f56c6c;
}
.progress-hint {
font-size: 12px;
color: #909399;
}
.urgent-fade-enter-active,
.urgent-fade-leave-active {
transition: opacity 0.22s ease;
}
.urgent-fade-enter-active .urgent-card,
.urgent-fade-leave-active .urgent-card {
transition: transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.urgent-fade-enter-from,
.urgent-fade-leave-to {
opacity: 0;
}
.urgent-fade-enter-from .urgent-card,
.urgent-fade-leave-to .urgent-card {
transform: scale(0.92) translateY(10px);
}
</style>
@@ -10,11 +10,13 @@ import {
relativeTime,
truncate,
type ClassDto,
type ClassPeriod,
type NotificationDto,
type NotificationPriority,
} from '@classhelper/shared';
import { classApi, notificationApi } from '@/api';
import { classApi, notificationApi, scheduleApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
const realtime = useRealtimeStore();
@@ -64,6 +66,13 @@ const rules: FormRules = {
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
};
/** 上课时段发布紧急通知的全屏二次确认状态 */
const urgentWarning = reactive({
visible: false,
period: null as ClassPeriod | null,
title: '',
});
function openCreate(): void {
form.classId = filter.classId || classes.value[0]?.id || '';
form.title = '';
@@ -76,15 +85,56 @@ async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const created = await notificationApi.create({
classId: form.classId,
title: form.title.trim(),
content: form.content,
priority: form.priority,
});
ElMessage.success(`通知已发布(${created.title}),已推送给学生客户端`);
formVisible.value = false;
await loadNotifications();
// 上课时段发布紧急通知:先查询班级上课状态,命中则弹全屏二次确认(3 秒倒计时)
if (form.priority === 'URGENT') {
const status = await scheduleApi.classStatus(form.classId).catch(() => null);
if (status?.inClass) {
urgentWarning.period = status.current;
urgentWarning.title = form.title.trim();
urgentWarning.visible = true;
return;
}
}
await doPublish(false);
}
/** 真正提交;confirmDuringClass 为 true 表示教师已在上课警告中确认 */
async function doPublish(confirmDuringClass: boolean): Promise<void> {
try {
const created = await notificationApi.create({
classId: form.classId,
title: form.title.trim(),
content: form.content,
priority: form.priority,
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
});
ElMessage.success(`通知已发布(${created.title}),已推送给学生客户端`);
formVisible.value = false;
await loadNotifications();
} catch (error) {
// 兜底:并发场景下服务端仍可能拦截(例如刚好上课铃响),此时同样弹出警告
const response = (
error as {
response?: { status?: number; data?: { code?: string; details?: { current?: ClassPeriod | null } } };
}
).response;
if (response?.status === 409 && response.data?.code === 'URGENT_DURING_CLASS') {
urgentWarning.period = response.data.details?.current ?? null;
urgentWarning.title = form.title.trim();
urgentWarning.visible = true;
}
}
}
function onUrgentConfirmed(): void {
urgentWarning.visible = false;
void doPublish(true);
}
function onUrgentCancelled(): void {
urgentWarning.visible = false;
ElMessage.info('已取消发布,紧急通知未发出');
}
async function removeNotification(row: NotificationDto): Promise<void> {
@@ -222,5 +272,14 @@ onUnmounted(() => {
<el-button type="primary" @click="submitForm">立即发布</el-button>
</template>
</el-dialog>
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) -->
<UrgentClassWarning
:visible="urgentWarning.visible"
:period="urgentWarning.period"
:pending-title="urgentWarning.title"
@cancel="onUrgentCancelled"
@confirm="onUrgentConfirmed"
/>
</div>
</template>