feat(desktop+web+server): ClassIsland 风格 UI、灵动岛动画/交互修复、作业上岛与"叫人"功能

## 灵动岛修复与动画优化(用户反馈)

- **收回不再出现"方框"闪烁**:卡片改为**固定逻辑尺寸 + 顶部居中锚定**,
  窗口只负责露出/裁切透明区域。此前卡片随窗口拉伸,形变过程中会看到被拉扁的圆角方块,
  胶囊里的铃铛/文字还会被垂直居中而"从上跳到下";两条问题同源,现已消除。
  冒烟用 15 次采样断言卡片尺寸恒为固定值(`396x308` / `260x38`),一旦出现中间值即失败。
- **点击屏幕任意位置收回**:展开态临时 `setFocusable(true)` 并取焦,点到别处即失焦 → 自动回缩为胶囊;
  收起后立即释放可聚焦性。断言覆盖 `focusable=true → 失焦后 mode=pill、focusable=false`。
- 动画继续打磨:危险/叫人形态改为**内部光晕**(卡片四周再留 4px 透明边,彻底避免窗口边界裁切出硬边)、
  尖角与圆角统一、形态尺寸各异(胶囊 268×44 / 详情 404×316 / 紧急 424×344 / 叫人 440×360)。

## 灵动岛"标为已读"真正同步通知中心

- 新增链路:灵动岛 `mark-read` → 主进程 `setMarkReadHandler` → 主窗口渲染进程 →
  `notificationApi.markRead` + 刷新 store(未读红点即时减少)。
- 真机链路断言:点击"标为已读"后 `read=false → true`、未读数 `1 → 0`(此前只从灵动岛队列移除)。

## 作业发布也上岛

- `homework:new` → 灵动岛 `kind=homework`:胶囊显示"新作业"(青蓝描边 + 书本图标),
  展开显示作业说明与"截止时间:…"。

## 新增"叫人"功能

- 服务端:新模块 `modules/calls`(`POST /api/calls`)——校验班级可写 + 学生属于该班,
  落库一条 HIGH 级通知(进通知中心、可用已读状态),广播 `notification:new` 到班级房间,
  并定向 `call:new` 给被叫学生。
- 共享层:`CALL_QUICK_PHRASES`(8 条快捷短语)、`buildCallTitle()`("请 XXX 同学找 XXX 老师",老师名已含"老师"不重复)、
  `ServerToClientEvents['call:new']`、`CreateCallRequest`。
- Web 管理端:学生管理列表新增 **叫人** 按钮 → 弹窗显示对象、快捷短语(点选/取消)、自定义消息(优先),发送即通知。
- 客户端:`call:new` → 灵动岛 `kind=call`,**上课时段也立即展开**(老师正在等这名学生),
  琥珀金卡片 +"叫人"徽标 +"收到"按钮,90 秒后自动收起。

## 客户端界面改为 ClassIsland 同款 Fluent 风格

- 主题令牌:Mica 浅底 `#f3f3f3`、亚克力卡片 `rgba(255,255,255,.7)` + `backdrop-filter`、
  强调色 `#0F6CBD`、半透明描边、卡片 8px / 控件 4px 圆角、极轻阴影。
- Element Plus 变量整体对齐(`--el-color-primary`、圆角、表头/行分隔、滚动条等)。
- 侧边导航改为 Fluent NavigationView 观感:白色半透明底、选中项强调色淡底 + 左侧 3px 强调条;
  品牌标改为强调色渐变圆角方块。附客户端五个页面实拍(`docs/screenshots/client/`)。

## 验证

- `pnpm verify:e2e` **68/68**(新增 6 项叫人:201 + 标题模板、自定义消息优先、上课时段允许、学生 403、通知中心可见、清理)
- `pnpm verify:desktop` **33/33**(新增:卡片固定尺寸、点击任意处收回、标为已读同步、作业上岛、叫人上课中立即展开、真实通知链路)
- `pnpm verify:web` **16/16**(新增叫人入口:弹窗、8 条快捷短语、自定义输入、短语可选中)
- `pnpm lint` / `format:check` / `typecheck` 全部通过;服务端安装包已重打并安装验证
This commit is contained in:
LoveChengke committed 2026-09-12 13:57:46 +08:00
1 parent f759aac0a2
commit 219f6ae83a
28 files changed
+1555 -156

No files matched your search

+218 -27
View File
@@ -1,6 +1,12 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { PRIORITY_LABELS, formatDate, type IslandNotification, type IslandState } from '@classhelper/shared';
import {
PRIORITY_LABELS,
formatDate,
type IslandNotification,
type IslandNotificationKind,
type IslandState,
} from '@classhelper/shared';
/**
* 灵动岛渲染进程。
@@ -25,8 +31,31 @@ 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 kind = computed<IslandNotificationKind>(() => notification.value?.kind ?? 'notification');
const isCall = computed(() => kind.value === 'call');
const isHomework = computed(() => kind.value === 'homework');
const queueCount = computed(() => state.value.queued.length);
/** 胶囊标题:按消息类型区分(新消息 / 新作业 / 叫人) */
const pillTitle = computed(() => {
if (isCall.value) return '老师叫你';
if (isHomework.value) return '新作业';
return isUrgent.value ? '紧急通知' : '新消息';
});
/** 展开态徽标:叫人 / 新作业 / 优先级文案 */
const badgeText = computed(() => {
if (isCall.value) return '叫人';
if (isHomework.value) return '新作业';
return priorityLabel.value;
});
const badgeClass = computed(() => {
if (isCall.value) return 'badge-call';
if (isHomework.value) return 'badge-homework';
return `badge-${(notification.value?.priority ?? 'NORMAL').toLowerCase()}`;
});
const priorityLabel = computed(() =>
notification.value ? (PRIORITY_LABELS[notification.value.priority] ?? notification.value.priority) : '',
);
@@ -37,6 +66,9 @@ const subtitle = computed(() => {
return `${teacher}${formatDate(notification.value.createdAt, true)}`;
});
/** 叫人消息下方的附加说明(例如"请到办公室找我") */
const callHint = computed(() => notification.value?.subtitle ?? '');
/** 下课补发时提示"其实上课期间就到了" */
const showAfterClassHint = computed(() => state.value.reason === 'after-class' && !state.value.inClass);
@@ -97,13 +129,26 @@ onMounted(async () => {
key="pill"
type="button"
class="island-card pill"
:class="{ urgent: isUrgent, 'after-class': showAfterClassHint }"
:class="{ urgent: isUrgent, call: isCall, homework: isHomework, 'after-class': showAfterClassHint }"
@click="expand"
>
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
<span class="pulse-dot" :class="{ urgent: isUrgent }"></span>
<span v-if="isUrgent || isCall" class="glow" aria-hidden="true"></span>
<span class="pulse-dot" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }"></span>
<span class="pill-icon">
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<!-- 铃铛(通知)/ 书本(作业)/ 喇叭(叫人) -->
<svg v-if="isHomework" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="isCall" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" 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"
@@ -112,7 +157,7 @@ onMounted(async () => {
</span>
<span class="pill-text">
<span class="pill-title">
{{ isUrgent ? '紧急通知' : '新消息' }}
{{ pillTitle }}
<template v-if="queueCount > 0">· 共 {{ queueCount + 1 }} 条</template>
</span>
<span class="pill-sub">{{ notification.teacherName || '老师' }} · 点击查看</span>
@@ -135,13 +180,25 @@ onMounted(async () => {
v-else-if="mode === 'expanded' && notification"
key="expanded"
class="island-card expanded"
:class="{ urgent: isUrgent }"
:class="{ urgent: isUrgent, call: isCall, homework: isHomework }"
@click="onCardClick"
>
<span v-if="isUrgent" class="glow" aria-hidden="true"></span>
<span v-if="isUrgent || isCall" 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">
<span class="head-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
<svg v-if="isHomework" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="isCall" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" 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"
@@ -149,10 +206,9 @@ onMounted(async () => {
</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 class="badge" :class="badgeClass">{{ badgeText }}</span>
<span v-if="isCall" class="live-dot call">老师正在等你</span>
<span v-else-if="isUrgent" class="live-dot">需立即查看</span>
<span v-else-if="showAfterClassHint" class="live-dot muted">下课后补发</span>
<span class="head-sub">{{ subtitle }}</span>
</div>
@@ -172,6 +228,8 @@ onMounted(async () => {
<h3 class="title">{{ notification.title }}</h3>
<div class="body">
<p class="content">{{ notification.content }}</p>
<p v-if="callHint" class="call-hint">{{ callHint }}</p>
<p v-if="notification.subtitle && !callHint" class="call-hint">{{ notification.subtitle }}</p>
</div>
<footer class="foot">
@@ -180,7 +238,7 @@ onMounted(async () => {
<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>
<button type="button" class="solid-btn" @click="dismiss">{{ isCall ? '收到' : '知道了' }}</button>
</span>
</footer>
</section>
@@ -214,18 +272,17 @@ body,
box-sizing: border-box;
}
/* 三态卡片绝对定位,使交叉淡入淡出(并行 Transition)不会互相挤压。
注意:窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切,
在屏幕上表现为"卡片周围一圈奇怪的光晕/硬边"。因此这里**只用 inset 阴影**,
所有装饰效果都画在卡片内部。 */
/* 三态卡片绝对定位 + **固定逻辑尺寸、水平居中锚定**。
关键:卡片不能跟随窗口拉伸,否则窗口形变过程中会看到一块被拉扁的"方框",
胶囊里的图标/文字还会被垂直居中而"从上面瞬移到下面"。
这里每种形态都是固定尺寸,窗口只负责露出/裁切这块透明区域,形变时卡片内容不缩放。
另:窗口只比卡片大 2~4px,向外的 box-shadow/光晕会被窗口边界裁切,
因此所有装饰只使用 inset 阴影。 */
.island-card {
position: absolute;
top: 2px;
right: 4px;
bottom: 2px;
left: 4px;
box-sizing: border-box;
border-radius: 24px;
border-radius: 22px;
color: #f7f8fa;
border: 1px solid rgba(255, 255, 255, 0.14);
background:
@@ -239,12 +296,77 @@ body,
background 0.28s ease;
}
/* 各形态固定尺寸(与主进程 ISLAND_SIZES 对齐:卡片刻意比窗口小 4~8px,四周留 2~4px 透明边) */
.island-card.pill {
left: calc(50% - 130px);
width: 260px;
height: 38px;
border-radius: 20px;
}
.island-card.expanded {
left: calc(50% - 198px);
width: 396px;
height: 308px;
border-radius: 24px;
}
.island-card.expanded.urgent {
left: calc(50% - 208px);
width: 416px;
height: 336px;
}
/* 叫人:卡片略大(对应主进程 CALL_SIZE 440x360) */
.island-card.expanded.call {
left: calc(50% - 216px);
width: 432px;
height: 352px;
}
/* 隐藏态占位:只保留结构,不占视觉空间 */
.hidden-placeholder {
left: 0;
width: 100%;
height: 38px;
opacity: 0;
cursor: default;
}
/* 紧急通知:红色描边 + 内部光晕呼吸(不外扩,避免窗口边界裁切出光晕硬边) */
.island-card.urgent {
border-color: rgba(255, 92, 92, 0.6);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.35);
}
/* 叫人:琥珀金描边(老师点名,需要学生动作) */
.island-card.call {
border-color: rgba(255, 193, 94, 0.65);
background:
radial-gradient(120% 140% at 50% -20%, rgba(255, 193, 94, 0.18), rgba(255, 255, 255, 0) 55%),
linear-gradient(180deg, #2a2417 0%, #0d0c09 100%);
}
.island-card.call .glow {
animation: call-breath 2s ease-in-out infinite;
}
@keyframes call-breath {
0%,
100% {
box-shadow:
inset 0 0 14px rgba(255, 193, 94, 0.2),
inset 0 0 0 1px rgba(255, 193, 94, 0.3);
}
50% {
box-shadow:
inset 0 0 26px rgba(255, 193, 94, 0.4),
inset 0 0 0 1px rgba(255, 193, 94, 0.55);
}
}
/* 新作业:青蓝描边 */
.island-card.homework {
border-color: rgba(108, 196, 255, 0.55);
}
.glow {
@@ -593,6 +715,75 @@ body,
transform: translateY(-1px);
}
/* ---------------------------------------------------------------- 消息类型样式 */
/* "叫人"(老师点名,琥珀金):图标底色、徽标、按钮 */
.island-card.pill.call .pill-icon {
color: #ffd79a;
background: rgba(255, 193, 94, 0.2);
}
.pulse-dot.call {
background: #ffc15e;
box-shadow: 0 0 0 0 rgba(255, 193, 94, 0.7);
}
.head-icon.call {
color: #ffd79a;
background: rgba(255, 193, 94, 0.22);
}
.badge-call {
color: #3a2600;
background: #ffc15e;
}
.live-dot.call {
color: #ffd79a;
}
.island-card.expanded.call .solid-btn {
color: #3a2600;
background: #ffc15e;
}
.island-card.expanded.call .body {
background: rgba(255, 193, 94, 0.08);
}
.call-hint {
margin: 8px 0 0;
font-size: 12px;
color: #ffd79a;
font-weight: 600;
}
/* 新作业(青蓝) */
.island-card.pill.homework .pill-icon {
color: #b7e2ff;
background: rgba(108, 196, 255, 0.18);
}
.pulse-dot.homework {
background: #6cc4ff;
box-shadow: 0 0 0 0 rgba(108, 196, 255, 0.6);
}
.head-icon.homework {
color: #b7e2ff;
background: rgba(108, 196, 255, 0.2);
}
.badge-homework {
color: #06263f;
background: #6cc4ff;
}
.island-card.expanded.homework .solid-btn {
color: #06263f;
background: #6cc4ff;
}
/* ---------------------------------------------------------------- 形变动画 */
/* 胶囊态:没有入场动画,直接出现 */
@@ -97,6 +97,11 @@ if (!gotLock) {
mainWindow.show();
mainWindow.focus();
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
} catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
+85 -20
View File
@@ -24,6 +24,9 @@ const SIZES: Record<IslandMode, { width: number; height: number }> = {
const URGENT_SIZE = { width: 424, height: 344 };
/** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */
const CALL_SIZE = { width: 440, height: 360 };
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
const TIMEOUTS = {
pill: 15_000,
@@ -31,6 +34,8 @@ const TIMEOUTS = {
afterClassPill: 25_000,
expanded: 20_000,
urgent: 45_000,
/** 叫人消息常驻更久,避免学生走开一会儿回来就看不到 */
call: 90_000,
};
export interface IslandPushContext {
@@ -76,10 +81,17 @@ class IslandController {
/** 灵动岛展开时点击"打开应用"交给外部处理 */
private openAppHandler: (() => void) | null = null;
/** 灵动岛点"标为已读"时交给外部(主窗口渲染进程)同步通知中心 */
private markReadHandler: ((id: string) => void) | null = null;
setOpenAppHandler(handler: () => void): void {
this.openAppHandler = handler;
}
setMarkReadHandler(handler: (id: string) => void): void {
this.markReadHandler = handler;
}
async init(rendererUrl: string | null): Promise<void> {
this.rendererUrl = rendererUrl;
if (this.win && !this.win.isDestroyed()) return;
@@ -117,6 +129,17 @@ class IslandController {
this.ready = false;
});
/**
* 点击屏幕任意位置收起:展开态临时允许窗口获得焦点,
* 用户点到别处(桌面、浏览器、其他应用)时窗口失焦 → 回缩为胶囊。
*/
win.on('blur', () => {
if (this.state.mode === 'expanded' && !this.state.inClass) {
logger.info('灵动岛:点击屏幕其他位置,回缩为胶囊');
this.handleAction({ action: 'collapse' });
}
});
this.win = win;
// 开发/冒烟时把灵动岛渲染进程的日志转发到主进程,便于排查"窗口空白"之类问题
@@ -161,10 +184,18 @@ class IslandController {
/* ------------------------------------------------------------ 对外行为 */
/**
* 收到一条通知。
* - 紧急:立刻展开(无视上课时段)
* 是否需要"立即展开、无视上课时段"。
* 紧急通知与"叫人"(老师点名)都属于:学生必须马上看到。
*/
private isImmediate(notification: IslandNotification): boolean {
return notification.priority === 'URGENT' || notification.kind === 'call';
}
/**
* 收到一条消息。
* - 紧急 / 叫人:立刻展开(无视上课时段)
* - 上课中:进队列并保持隐藏(下课后由 setClassState 触发弹出)
* - 其它:显示"新消息"胶囊,等待点击展开
* - 其它:显示"新消息/新作业"胶囊,等待点击展开
*/
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
const inClass = context.inClass ?? this.state.inClass;
@@ -172,14 +203,19 @@ class IslandController {
if (context.currentPeriodEnd !== undefined)
this.state.currentPeriodEnd = context.currentPeriodEnd ?? null;
if (notification.priority === 'URGENT') {
// 紧急插播:如果正在展示别的通知,把它放回队列
if (this.isImmediate(notification)) {
// 插播:如果正在展示别的消息,把它放回队列
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}`);
const reason = notification.kind === 'call' ? 'call' : 'urgent';
this.activate(notification, reason, 'expanded');
logger.info(
notification.kind === 'call'
? `灵动岛叫人消息:${notification.title}`
: `灵动岛紧急插播:${notification.title}`,
);
return;
}
@@ -245,18 +281,24 @@ class IslandController {
handleAction(payload: IslandActionPayload): void {
switch (payload.action) {
case 'expand':
// 上课时段一律不显示灵动岛(只有紧急通知能自动展开)
case 'expand': {
// 上课时段一律不显示灵动岛(只有紧急通知 / 叫人才会自动展开)
if (this.state.inClass) break;
if (this.state.active) {
const active = this.state.active;
if (active) {
this.setState({ mode: 'expanded' });
this.scheduleCollapse(
this.state.active.priority === 'URGENT' ? TIMEOUTS.urgent : TIMEOUTS.expanded,
this.isImmediate(active)
? active.kind === 'call'
? TIMEOUTS.call
: TIMEOUTS.urgent
: TIMEOUTS.expanded,
);
}
break;
}
case 'collapse':
// 点击卡片空白处 / 右上角收起按钮:回缩到灵动岛(胶囊),不直接消失
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
if (this.state.inClass) {
this.hideImmediately();
break;
@@ -272,12 +314,18 @@ class IslandController {
this.dismissActive();
break;
case 'mark-read': {
const queue = this.state.queued.filter((item) => item.id !== payload.id);
const id = payload.id;
const queue = this.state.queued.filter((item) => item.id !== id);
this.state.queued = queue;
if (this.state.active?.id === payload.id) this.state.active = null;
if (this.state.active?.id === id) this.state.active = null;
this.state.updatedAt = Date.now();
if (this.state.mode === 'hidden') this.emit();
else this.dismissActive();
// 通知中心同步:转交主窗口渲染进程调用"标记已读"接口并刷新列表
if (id) {
logger.info(`灵动岛标记已读:${id}`);
this.markReadHandler?.(id);
}
break;
}
case 'open-app':
@@ -308,6 +356,7 @@ class IslandController {
this.emit();
}
if (!this.win || this.win.isDestroyed()) return;
this.win.setFocusable(false);
if (this.win.isVisible()) this.win.hide();
this.win.setOpacity(1);
}
@@ -419,13 +468,17 @@ class IslandController {
private syncWindow(): void {
if (!this.win || this.win.isDestroyed()) return;
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知"允许出现在屏幕上)
const urgentShowing = this.state.mode === 'expanded' && this.state.active?.priority === 'URGENT';
if (this.state.inClass && !urgentShowing) {
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知/叫人"允许出现在屏幕上)
const immediateShowing =
this.state.mode === 'expanded' && this.state.active !== null && this.isImmediate(this.state.active);
if (this.state.inClass && !immediateShowing) {
this.hideImmediately();
return;
}
// 展开态才允许获得焦点,这样点击屏幕其他位置会 blur → 自动收起
this.applyFocusable();
if (this.state.mode === 'hidden') {
this.fadeOut();
return;
@@ -433,8 +486,12 @@ class IslandController {
const size =
this.state.mode === 'expanded'
? this.state.active?.priority === 'URGENT'
? URGENT_SIZE
? this.state.active
? this.isImmediate(this.state.active)
? this.state.active.kind === 'call'
? CALL_SIZE
: URGENT_SIZE
: SIZES.expanded
: SIZES.expanded
: SIZES.pill;
@@ -465,6 +522,14 @@ class IslandController {
this.fadeToken += 1;
}
/** 展开态允许聚焦(用于"点击屏幕任意处收起"),其余时间不抢焦点 */
private applyFocusable(): void {
if (!this.win || this.win.isDestroyed()) return;
const shouldFocus = this.state.mode === 'expanded';
if (this.win.isFocusable() !== shouldFocus) this.win.setFocusable(shouldFocus);
if (shouldFocus) this.win.focus();
}
private fadeIn(): void {
if (!this.win || this.win.isDestroyed()) return;
const token = ++this.fadeToken;
@@ -561,4 +626,4 @@ export function registerIslandIpc(): void {
});
}
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE };
export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE, CALL_SIZE as ISLAND_CALL_SIZE };
+227 -7
View File
@@ -1,10 +1,10 @@
import fs from 'node:fs';
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
import type { BrowserWindow, NativeImage } from 'electron';
import type { IslandNotification } from '@classhelper/shared';
import { getDiagnostics } from './ipc.js';
import { island, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
interface SmokeResult {
name: string;
@@ -87,9 +87,10 @@ async function captureIsland(
name: string,
expected: { width: number; height: number },
): Promise<IslandShotStats | null> {
const dir = process.env.ISLAND_SHOTS_DIR;
// 截图统计总是执行(像素断言不依赖环境变量);ISLAND_SHOTS_DIR 只控制是否落盘留档
const dir = process.env.ISLAND_SHOTS_DIR ?? '';
const win = island.getWindow();
if (!dir || !win || win.isDestroyed() || !win.isVisible()) return null;
if (!win || win.isDestroyed() || !win.isVisible()) return null;
try {
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
await win.webContents.executeJavaScript(
@@ -100,9 +101,11 @@ async function captureIsland(
await win.webContents.executeJavaScript(
`document.documentElement.style.background = 'transparent'; document.body.style.background = 'transparent'; true`,
);
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, `${name}.png`);
fs.writeFileSync(filePath, image.toPNG());
const filePath = dir ? path.join(dir, `${name}.png`) : '';
if (filePath) {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, image.toPNG());
}
const size = image.getSize();
const stats: IslandShotStats = {
@@ -348,6 +351,131 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
`chevronClicked=${chevronClicked} mode=${afterChevron.mode}`,
);
// 4.4) 收回过程中卡片不能出现"被拉扁的方框":
// 采样卡片尺寸,必须始终是固定的胶囊/详情尺寸之一,绝不出现中间值。
island.handleAction({ action: 'expand' });
await sleep(700);
const shrinkSamples: string[] = [];
const shrinkSampler = setInterval(() => {
void islandWindow?.webContents
.executeJavaScript(
`(() => {
const node = document.querySelector('.island-card');
if (!node) return '';
const rect = node.getBoundingClientRect();
return Math.round(rect.width) + 'x' + Math.round(rect.height);
})()`,
)
.then((value: string) => {
if (value) shrinkSamples.push(value);
})
.catch(() => undefined);
}, 40);
island.handleAction({ action: 'collapse' });
await sleep(700);
clearInterval(shrinkSampler);
await sleep(120);
const allowedSizes = new Set(['396x308', '416x336', '432x352', '260x38']);
const stretched = [...new Set(shrinkSamples)].filter((size) => !allowedSizes.has(size));
record(
'收回动画不出现被拉伸的"方框"(卡片保持固定尺寸)',
shrinkSamples.length >= 3 && stretched.length === 0,
`采样=${shrinkSamples.length} 尺寸集合=[${[...new Set(shrinkSamples)].join(',')}] 异常尺寸=[${stretched.join(',')}]`,
);
// 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊
island.handleAction({ action: 'expand' });
await sleep(700);
const focusable = islandWindow?.isFocusable() ?? false;
// 程序化触发失焦:等价于用户点到别处(冒烟窗口本身不显示,无法真的点击桌面)
islandWindow?.emit('blur');
await sleep(700);
const afterBlur = island.getState();
record(
'点击屏幕任意位置回缩为胶囊(展开时可聚焦 + 失焦收起)',
focusable && islandWindow?.isFocusable() === false && afterBlur.mode === 'pill',
`展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`,
);
// 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示截止时间
island.handleAction({ action: 'dismiss' });
await sleep(400);
island.pushNotification(
{
...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'),
kind: 'homework',
subtitle: '截止时间:2026-09-15 22:00',
teacherName: '张老师',
},
{ inClass: false },
);
await sleep(700);
const homeworkState = island.getState();
const homeworkDom = await islandWindow?.webContents.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card.pill.homework');
return { cls: card?.className ?? '', title: card?.querySelector('.pill-title')?.textContent?.trim() ?? '' };
})()`,
);
record(
'新作业发布也上岛(胶囊带"新作业"样式)',
homeworkState.mode === 'pill' &&
homeworkState.active?.kind === 'homework' &&
(homeworkDom?.title ?? '').includes('新作业'),
`mode=${homeworkState.mode} kind=${homeworkState.active?.kind} 胶囊标题="${homeworkDom?.title ?? '-'}" 类名=${homeworkDom?.cls ?? '-'}`,
);
island.handleAction({ action: 'expand' });
await sleep(700);
const homeworkHint = await islandWindow?.webContents.executeJavaScript(
`document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? ''`,
);
record(
'作业详情显示截止时间',
(homeworkHint ?? '').includes('截止时间'),
`附加说明="${homeworkHint ?? '-'}"`,
);
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
// 4.7) "叫人":上课时段也立即展开,展示"请 XXX 同学找 XXX 老师"
island.handleAction({ action: 'dismiss' });
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
await sleep(400);
island.pushNotification(
{
...makeNotification('smoke-call', 'HIGH', '请 王小明 同学找 张老师'),
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
teacherName: '张老师',
},
{ inClass: true },
);
await sleep(900);
const callState = island.getState();
const callDom = await islandWindow?.webContents.executeJavaScript(
`(() => ({
badge: document.querySelector('.island-card.expanded .badge')?.textContent?.trim() ?? '',
title: document.querySelector('.island-card.expanded .title')?.textContent?.trim() ?? '',
solid: document.querySelector('.island-card.expanded .solid-btn')?.textContent?.trim() ?? '',
hint: document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? '',
cls: document.querySelector('.island-card.expanded')?.className ?? '',
}))()`,
);
record(
'叫人消息上岛(上课时段也立即展开,无需点击)',
callState.mode === 'expanded' &&
callState.active?.kind === 'call' &&
callState.reason === 'call' &&
callState.inClass === true &&
(callDom?.title ?? '').includes('请') &&
(callDom?.title ?? '').includes('找'),
`mode=${callState.mode} kind=${callState.active?.kind} reason=${callState.reason} 上课中=${callState.inClass} ` +
`徽标=${callDom?.badge} 标题="${callDom?.title}" 按钮=${callDom?.solid}`,
);
await captureIsland('island-8-call', ISLAND_CALL_SIZE);
island.handleAction({ action: 'dismiss' });
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
await sleep(400);
// 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与
// 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。
const shotDetails = islandShots.map((shot) => {
@@ -403,6 +531,93 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
results.push({ name: '灵动岛用例收尾', ok: true, detail: '已隐藏' });
}
/**
* 真实通知链路自检(含"标为已读"同步)。
*
* 与 runIslandChecks 的区别:那里直接调 IPC 验证状态机;这里走完
* "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路,
* 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。
*/
async function runIslandRealtimeCheck(
win: BrowserWindow,
): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> {
const scenario = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.islandRealtimeScenario) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.islandRealtimeScenario();
})()`,
);
if (!scenario?.ok) {
return {
delivered: false,
deliveryDetail: `投递失败:${scenario?.detail ?? '未知原因'}`,
markRead: false,
markReadDetail: '未投递成功,跳过',
};
}
// 等通知经实时通道到达并弹出胶囊
const deadline = Date.now() + 10_000;
let state = island.getState();
while (Date.now() < deadline) {
state = island.getState();
if (state.mode === 'pill' && state.active?.id === scenario.notificationId) break;
await sleep(120);
}
const visible = island.getWindow()?.isVisible() ?? false;
const delivered = state.mode === 'pill' && state.active?.id === scenario.notificationId && visible;
const deliveryDetail =
`通知=${scenario.notificationId} 客户端判定上课中=${scenario.inClass} ` +
`灵动岛 mode=${state.mode} 可见=${visible}`;
// 扩展灵动岛 → 点"标为已读" → 通知中心应同步为已读
let markRead = false;
let markReadDetail = '未执行';
if (delivered) {
const before = await win.webContents.executeJavaScript(
`(async () => window.__classhelperSmoke__.islandReadState(${JSON.stringify(scenario.notificationId)}))()`,
);
island.handleAction({ action: 'expand' });
await sleep(700);
const clicked = await island.getWindow()?.webContents.executeJavaScript(
`(() => {
const button = Array.from(document.querySelectorAll('.island-card.expanded .ghost-btn')).find((node) =>
(node.textContent ?? '').includes('标为已读'),
);
if (!button) return false;
button.click();
return true;
})()`,
);
await sleep(900);
const after = await win.webContents.executeJavaScript(
`(async () => window.__classhelperSmoke__.islandReadState(${JSON.stringify(scenario.notificationId)}))()`,
);
markRead = clicked === true && after?.found === true && after?.read === true;
markReadDetail =
`点击标为已读=${clicked} 点击前 read=${before?.read}(未读 ${before?.unreadCount})` +
` 点击后 read=${after?.read}(未读 ${after?.unreadCount})标题="${after?.title ?? '-'}"`;
}
// 清理:删除本次自检产生的通知,避免污染演示数据
const cleanup = await win.webContents
.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.islandRealtimeCleanup) return { ok: false, detail: '缺少清理钩子' };
return await window.__classhelperSmoke__.islandRealtimeCleanup(
${JSON.stringify(scenario.notificationId)},
${JSON.stringify(scenario.teacherToken)},
);
})()`,
)
.catch(() => null);
console.log(`[SMOKE] 真实链路清理:${cleanup?.detail ?? '未执行'}`);
island.handleAction({ action: 'dismiss' });
await sleep(300);
return { delivered, deliveryDetail, markRead, markReadDetail };
}
/**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
@@ -545,6 +760,11 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
String(online?.detail ?? ''),
);
// 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊;
// 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读")
const realtime = await runIslandRealtimeCheck(win);
record('真实通知链路(Socket.IO → 灵动岛胶囊)', realtime.delivered, realtime.deliveryDetail);
record('灵动岛"标为已读"同步通知中心', realtime.markRead, realtime.markReadDetail);
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
const navigation = await win.webContents.executeJavaScript(
`(async () => {
@@ -29,6 +29,10 @@ const bridge: DesktopBridge = {
ipcRenderer.send('island:class-state', payload);
},
islandGetState: () => ipcRenderer.invoke('island:get-state'),
/** 灵动岛点了"标为已读":主进程转交渲染进程同步通知中心 */
onIslandMarkRead: (handler: (id: string) => void): void => {
ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id));
},
};
contextBridge.exposeInMainWorld('desktop', bridge);
+4 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { startIslandBridge, stopIslandBridge } from './island/bridge.js';
import { startIslandBridge, stopIslandBridge, subscribeIslandMarkRead } from './island/bridge.js';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useRealtimeStore } from './stores/realtime.js';
@@ -33,6 +33,9 @@ onMounted(async () => {
// 灵动岛:开始按课表计算上课状态(上课隐藏、下课自动弹出)
if (auth.token) startIslandBridge();
// 灵动岛"标为已读" → 同步通知中心(未读红点)
subscribeIslandMarkRead();
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
} finally {
+106
View File
@@ -215,6 +215,109 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
}
}
/**
* 冒烟自检 5(需后端在线):真实通知链路 → 灵动岛。
*
* 前面的灵动岛用例直接调 IPC 验证"状态机",这一条走**真机链路**:
* 教师账号给"当前登录学生所在班级"发一条通知 → 服务端 Socket.IO 广播 →
* 客户端 realtime store → bridge → 主进程灵动岛。主进程随后断言灵动岛弹出胶囊,
* 并继续验证"在灵动岛点标为已读 → 通知中心同步为已读"。
*/
export async function islandRealtimeScenario(): Promise<{
ok: boolean;
detail: string;
notificationId?: string;
teacherToken?: string;
title?: string;
classId?: string;
inClass?: boolean;
}> {
const [{ useAuthStore }, { getIslandClassContext }] = await Promise.all([
import('../stores/auth.js'),
import('../island/bridge.js'),
]);
const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000';
const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
const title = `灵动岛真机链路自检 ${Date.now()}`;
try {
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
}).then((response) => response.json());
const teacherToken: string | undefined = login?.data?.token;
if (!teacherToken) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
const created = await fetch(`${serverUrl}/api/notifications`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${teacherToken}` },
body: JSON.stringify({
classId,
title,
content: '自动化验证:这条通知用于确认「服务端广播 → 客户端实时通道 → 灵动岛」整条链路可用。',
priority: 'NORMAL',
}),
}).then((response) => response.json());
const notificationId: string | undefined = created?.data?.id;
if (!notificationId)
return { ok: false, detail: `发布通知失败:${JSON.stringify(created).slice(0, 160)}` };
const context = getIslandClassContext();
return {
ok: true,
detail: `classId=${classId} 通知=${notificationId} 客户端判定上课中=${context.inClass}`,
notificationId,
teacherToken,
title,
classId,
inClass: context.inClass,
};
} catch (error) {
return { ok: false, detail: `投递失败:${error instanceof Error ? error.message : String(error)}` };
}
}
/** 冒烟收尾:删除真实链路自检产生的通知 */
export async function islandRealtimeCleanup(
notificationId: string,
teacherToken: string,
): Promise<SmokeCheckResult> {
try {
const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${teacherToken}` },
});
return { ok: response.ok, detail: `清理通知 ${notificationId} → HTTP ${response.status}` };
} catch (error) {
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
}
}
/**
* 读取某条通知在本地通知中心里的已读状态。
* 用于验证"灵动岛点标为已读 → 通知中心也变成已读、未读数下降"。
*/
export async function islandReadState(notificationId: string): Promise<{
found: boolean;
read: boolean;
unreadCount: number;
title: string;
}> {
const { useNotificationStore } = await import('../stores/notifications.js');
const store = useNotificationStore();
if (store.items.length === 0) await store.load().catch(() => undefined);
const target = store.items.find((item) => item.id === notificationId);
return {
found: Boolean(target),
read: Boolean(target?.read),
unreadCount: store.unreadCount,
title: target?.title ?? '',
};
}
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -237,6 +340,9 @@ export function registerSmokeHooks(): void {
offlineScenario,
layoutNavigationSelfTest,
onlineScenario,
islandRealtimeScenario,
islandRealtimeCleanup,
islandReadState,
sessionCleanup,
};
}
@@ -1,7 +1,9 @@
import {
SOCKET_EVENTS,
formatDate,
resolveClassStatus,
type ClassPeriod,
type HomeworkDto,
type NotificationDto,
type ScheduleDto,
} from '@classhelper/shared';
@@ -9,6 +11,7 @@ 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 { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
/**
@@ -82,11 +85,68 @@ export function pushNotificationToIsland(notification: NotificationDto): void {
createdAt: notification.createdAt,
courseName: currentPeriod?.courseName ?? null,
teacherName: notification.creator?.name ?? null,
kind: 'notification',
},
context: getIslandClassContext(),
});
}
/** 新作业上岛(homework:new):胶囊提示"新作业",展开看截止时间 */
export function pushHomeworkToIsland(homework: HomeworkDto): void {
const due = homework.dueAt ? formatDate(homework.dueAt, true) : '未设置截止时间';
window.desktop?.islandPush({
notification: {
id: `homework-${homework.id}`,
title: homework.title,
content: homework.content?.trim() || '(老师没有填写作业说明)',
priority: 'NORMAL',
createdAt: homework.createdAt,
courseName: homework.course?.name ?? null,
teacherName: homework.creator?.name ?? null,
kind: 'homework',
subtitle: `截止时间:${due}`,
},
context: getIslandClassContext(),
});
}
/** "叫人"上岛:无论是否上课都立即展开,展示"请 XXX 同学找 XXX 老师" */
export function pushCallToIsland(call: NotificationDto): void {
window.desktop?.islandPush({
notification: {
id: call.id,
title: call.title,
content: call.content?.trim() || '老师正在等你,请尽快前往。',
priority: call.priority,
createdAt: call.createdAt,
courseName: null,
teacherName: call.creator?.name ?? null,
kind: 'call',
subtitle: '请尽快前往,收到后点「收到」',
},
context: getIslandClassContext(),
});
}
/**
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
* 由 App.vue 在挂载时订阅一次。
*/
export function subscribeIslandMarkRead(): void {
window.desktop?.onIslandMarkRead?.((id) => {
void (async () => {
const store = useNotificationStore();
try {
await store.markRead(id);
} catch {
// 离线或通知已被删除:本地先记为已读,等下次同步纠正
const target = store.items.find((item) => item.id === id);
if (target) target.read = true;
}
})();
});
}
export function startIslandBridge(): void {
if (tickTimer) return;
@@ -97,10 +97,12 @@ onUnmounted(() => {
<template>
<el-container class="layout">
<el-aside width="190px" class="aside">
<el-aside width="200px" class="aside ch-nav">
<div class="brand">
<el-icon :size="20"><School /></el-icon>
<span>班级小助手</span>
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
@@ -170,11 +172,10 @@ onUnmounted(() => {
<style scoped>
.layout {
height: 100vh;
background: var(--ch-bg);
}
.aside {
background: var(--ch-sidebar);
color: #fff;
display: flex;
flex-direction: column;
}
@@ -182,29 +183,38 @@ onUnmounted(() => {
.brand {
display: flex;
align-items: center;
gap: 8px;
height: 54px;
gap: 10px;
height: 52px;
padding: 0 16px;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-size: 14px;
color: var(--ch-text);
}
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
.brand-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
}
.brand-text {
letter-spacing: 0.2px;
}
.menu {
flex: 1;
border-right: none;
background: transparent;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #fff;
}
.menu :deep(.el-menu-item) {
margin: 2px 8px;
border-radius: 6px;
}
.menu :deep(.el-menu-item.is-active) {
background: #409eff;
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
--el-menu-text-color: var(--ch-text);
--el-menu-active-color: var(--ch-text);
}
.menu-badge {
@@ -213,22 +223,25 @@ onUnmounted(() => {
.aside-footer {
padding: 10px 16px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid var(--ch-divider);
}
.aside-meta {
font-size: 12px;
color: #8fa3b8;
line-height: 1.8;
color: var(--ch-text-tertiary);
line-height: 1.9;
}
.header {
background: #fff;
height: 52px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--ch-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px;
}
.header-left {
@@ -240,15 +253,22 @@ onUnmounted(() => {
.server-url {
font-size: 12px;
color: #909399;
color: var(--ch-text-tertiary);
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: var(--ch-radius-control);
cursor: pointer;
outline: none;
transition: background 0.15s ease;
}
.user-chip:hover {
background: rgba(0, 0, 0, 0.04);
}
.main {
@@ -11,7 +11,7 @@ import {
type ServerToClientEvents,
} from '@classhelper/shared';
import { socketUrlOf } from '../config.js';
import { pushNotificationToIsland } from '../island/bridge.js';
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
@@ -79,9 +79,24 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
mark(SOCKET_EVENTS.homeworkNew);
ElMessage.success(`新作业:${payload.title}`);
// 作业发布也上岛:胶囊提示"新作业",点击展开看截止时间与要求
pushHomeworkToIsland(payload);
dispatch(SOCKET_EVENTS.homeworkNew, payload);
});
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.callNew);
ElMessage({
message: payload.title,
type: 'warning',
duration: 8000,
showClose: true,
});
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
pushCallToIsland(payload);
dispatch(SOCKET_EVENTS.callNew, payload);
});
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto & { deleted?: boolean }) => {
mark(SOCKET_EVENTS.homeworkUpdated);
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
@@ -1,7 +1,65 @@
/* ============================================================================
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
*
* 参考 ClassIsland(Windows 课件表应用)的观感:
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
* ========================================================================== */
:root {
--ch-bg: #f5f7fa;
--ch-border: #e4e7ed;
--ch-sidebar: #1f2d3d;
/* Fluent 强调色 */
--ch-accent: #0f6cbd;
--ch-accent-hover: #115ea3;
--ch-accent-soft: rgba(15, 108, 189, 0.08);
--ch-accent-soft-strong: rgba(15, 108, 189, 0.14);
/* 层次底色(Mica / Layer) */
--ch-bg: #f3f3f3;
--ch-layer: rgba(255, 255, 255, 0.7);
--ch-layer-solid: #ffffff;
--ch-layer-alt: rgba(249, 249, 249, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.5);
/* 描边(Fluent 用半透明黑,避免生硬的灰线) */
--ch-border: rgba(0, 0, 0, 0.06);
--ch-border-strong: rgba(0, 0, 0, 0.1);
--ch-divider: rgba(0, 0, 0, 0.05);
/* 文本 */
--ch-text: #1b1b1b;
--ch-text-secondary: #5c5c5c;
--ch-text-tertiary: #8a8a8a;
/* 圆角与阴影 */
--ch-radius-card: 8px;
--ch-radius-control: 4px;
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
/* 兼容旧变量名 */
--ch-sidebar: #ffffff;
/* Element Plus 变量对齐 Fluent */
--el-color-primary: #0f6cbd;
--el-color-primary-light-3: #4d94d1;
--el-color-primary-light-5: #7fb1de;
--el-color-primary-light-7: #b3d0ec;
--el-color-primary-light-8: #d0e2f4;
--el-color-primary-light-9: #e7f0f9;
--el-color-primary-dark-2: #115ea3;
--el-border-radius-base: 4px;
--el-border-radius-small: 3px;
--el-text-color-primary: #1b1b1b;
--el-text-color-regular: #3a3a3a;
--el-text-color-secondary: #5c5c5c;
--el-border-color: rgba(0, 0, 0, 0.1);
--el-border-color-light: rgba(0, 0, 0, 0.06);
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
--el-fill-color-blank: transparent;
--el-font-size-base: 13px;
}
* {
@@ -17,11 +75,15 @@ body,
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-family:
'Segoe UI Variable Text', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
background: var(--ch-bg);
color: #303133;
color: var(--ch-text);
/* 桌面端禁用整页选中,更接近原生应用体验 */
user-select: none;
-webkit-font-smoothing: antialiased;
}
input,
@@ -29,6 +91,8 @@ textarea {
user-select: text;
}
/* ---------------------------------------------------------------- 页面骨架 */
.page {
padding: 16px 20px 24px;
}
@@ -39,25 +103,26 @@ textarea {
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
margin-bottom: 12px;
}
.page-title {
margin: 0;
font-size: 18px;
font-size: 20px;
font-weight: 600;
letter-spacing: 0.2px;
}
.page-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #909399;
font-size: 12.5px;
color: var(--ch-text-secondary);
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex-wrap: wrap;
}
@@ -70,13 +135,161 @@ textarea {
}
.text-muted {
color: #909399;
color: var(--ch-text-tertiary);
}
.offline-banner {
margin: 0 0 12px;
}
/* ---------------------------------------------------------------- 卡片 / 分组 */
/* 亚克力卡片:半透明底 + 极细描边 + 极轻阴影(ClassIsland 的内容卡片观感) */
.el-card {
border-radius: var(--ch-radius-card) !important;
border: 1px solid var(--ch-border) !important;
background: var(--ch-layer) !important;
backdrop-filter: blur(20px) saturate(1.1);
box-shadow: var(--ch-shadow-card) !important;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.el-card:hover {
border-color: var(--ch-border-strong) !important;
}
.el-card__header {
padding: 12px 16px !important;
border-bottom: 1px solid var(--ch-divider) !important;
font-weight: 600;
font-size: 13.5px;
}
.el-card__body {
padding: 14px 16px !important;
}
/* ---------------------------------------------------------------- 控件 */
.el-button {
border-radius: var(--ch-radius-control) !important;
font-weight: 500;
}
.el-button--default {
background: rgba(255, 255, 255, 0.7) !important;
border-color: var(--ch-border-strong) !important;
}
.el-button--default:hover {
background: rgba(0, 0, 0, 0.04) !important;
}
.el-button--primary {
box-shadow: 0 1px 2px rgba(15, 108, 189, 0.2);
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
border-radius: var(--ch-radius-control) !important;
background: rgba(255, 255, 255, 0.7) !important;
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--ch-accent) inset !important;
}
.el-tag {
border-radius: var(--ch-radius-control) !important;
}
.el-dialog,
.el-drawer {
border-radius: var(--ch-radius-card) !important;
background: var(--ch-layer-solid) !important;
}
.el-message-box {
border-radius: var(--ch-radius-card) !important;
}
.el-popper,
.el-dropdown__popper {
border-radius: var(--ch-radius-card) !important;
box-shadow: var(--ch-shadow-flyout) !important;
}
/* ---------------------------------------------------------------- 表格 */
.el-table {
--el-table-border-color: var(--ch-divider);
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
background: transparent !important;
}
.el-table th.el-table__cell {
background: transparent !important;
font-weight: 600;
color: var(--ch-text-secondary);
}
.el-table td.el-table__cell,
.el-table th.el-table__cell {
border-bottom: 1px solid var(--ch-divider) !important;
}
.el-table__inner-wrapper::before {
display: none;
}
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
.ch-nav {
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(24px) saturate(1.2);
border-right: 1px solid var(--ch-border);
}
.ch-nav .el-menu-item {
height: 40px;
line-height: 40px;
margin: 2px 8px;
border-radius: var(--ch-radius-control);
color: var(--ch-text);
font-weight: 500;
position: relative;
}
.ch-nav .el-menu-item:hover {
background: rgba(0, 0, 0, 0.04);
}
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
.ch-nav .el-menu-item.is-active {
background: var(--ch-accent-soft);
color: var(--ch-text);
}
.ch-nav .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--ch-accent);
}
/* ---------------------------------------------------------------- 课表时间轴 */
.timetable-cell {
min-height: 30px;
}
@@ -87,7 +300,7 @@ textarea {
.timetable-meta {
font-size: 12px;
color: #909399;
color: var(--ch-text-tertiary);
}
.unread-dot {
@@ -95,19 +308,27 @@ textarea {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
background: #d13438;
margin-right: 6px;
}
/* 滚动条美化(Windows 默认滚动条较粗) */
/* ---------------------------------------------------------------- 滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: #c8ccd4;
border-radius: 4px;
background: rgba(0, 0, 0, 0.18);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
background-clip: content-box;
}
::-webkit-scrollbar-track {
+23
View File
@@ -55,6 +55,8 @@ export interface DesktopBridge {
islandSetClassState(payload: IslandClassStatePayload): void;
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
islandGetState(): Promise<IslandState>;
/** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */
onIslandMarkRead(handler: (id: string) => void): void;
}
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
@@ -76,6 +78,27 @@ declare global {
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(): Promise<{ ok: boolean; detail: string }>;
/** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */
islandRealtimeScenario(): Promise<{
ok: boolean;
detail: string;
notificationId?: string;
teacherToken?: string;
title?: string;
classId?: string;
inClass?: boolean;
}>;
islandRealtimeCleanup(
notificationId: string,
teacherToken: string,
): Promise<{ ok: boolean; detail: string }>;
/** 读取某条通知在通知中心里的已读状态(验证灵动岛"标为已读"链路) */
islandReadState(notificationId: string): Promise<{
found: boolean;
read: boolean;
unreadCount: number;
title: string;
}>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
};
}
+72 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env node
/**
* 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
@@ -548,6 +548,77 @@ async function main() {
record('清理探测课表', removedProbe.status === 200, `status=${removedProbe.status}`);
}
// ---------------------------------------------------------------- 6.3 叫人
if (classId && studentUser) {
const callByTeacher = await api('/calls', {
method: 'POST',
token: teacherToken,
body: {
classId,
studentId: studentUser.id,
quickPhrase: '请到办公室找我',
},
});
const callTitle = callByTeacher.payload?.data?.title ?? '';
record(
'叫人接口(教师点名 → 201 + "请 XXX 同学找 XXX 老师")',
callByTeacher.status === 201 && callTitle.includes('请') && callTitle.includes('找'),
`status=${callByTeacher.status} title="${callTitle}"`,
);
const callWithMessage = await api('/calls', {
method: 'POST',
token: teacherToken,
body: {
classId,
studentId: studentUser.id,
quickPhrase: '请到讲台找我',
message: '带上昨天的数学作业本',
},
});
record(
'叫人支持自定义消息(优先于快捷短语)',
callWithMessage.status === 201 && callWithMessage.payload?.data?.content === '带上昨天的数学作业本',
`status=${callWithMessage.status} content="${callWithMessage.payload?.data?.content ?? ''}"`,
);
// 上课时段也允许叫人(不受 409 限制,因为老师确实需要学生马上过来)
const callDuringClass = await api('/calls', {
method: 'POST',
token: teacherToken,
body: { classId, studentId: studentUser.id, quickPhrase: '请马上来一趟' },
});
record(
'上课时段允许叫人(不受紧急通知 409 限制)',
callDuringClass.status === 201,
`status=${callDuringClass.status}`,
);
const studentCalls = await api('/calls', {
method: 'POST',
token: studentToken,
body: { classId, studentId: studentUser.id, quickPhrase: '学生不能叫人' },
});
record('学生调用叫人接口被拒绝(403)', studentCalls.status === 403, `status=${studentCalls.status}`);
const studentListAfterCall = await api('/notifications', { token: studentToken });
const receivedCall = (studentListAfterCall.payload?.data ?? []).find(
(item) => item.id === callWithMessage.payload?.data?.id,
);
record(
'学生通知中心能看到叫人消息(含未读状态)',
Boolean(receivedCall) && receivedCall.read === false,
`found=${Boolean(receivedCall)} read=${receivedCall?.read ?? '-'}`,
);
// 清理:删除本次叫人产生的通知
for (const created of [callByTeacher, callWithMessage, callDuringClass]) {
const id = created.payload?.data?.id;
if (id) await api(`/notifications/${id}`, { method: 'DELETE', token: teacherToken });
}
record('清理叫人测试数据', true, '已删除 3 条叫人通知');
}
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
@@ -0,0 +1,25 @@
import { Router } from 'express';
import { sendCreated } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
import * as callService from './calls.service.js';
const router = Router();
router.use(authenticate());
/**
* POST /api/calls - 叫人(老师点名让学生过来)
* 学生端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
*/
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createCallSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
});
export const callsModule = defineModule({
name: 'calls',
basePath: '/calls',
router,
});
@@ -0,0 +1,23 @@
import { z } from 'zod';
/**
* "叫人"请求:老师选一位学生 + 快捷短语/自定义消息,让学生在灵动岛上立即收到
* "请 XXX 同学找 XXX 老师"。
*/
export const createCallSchema = z
.object({
/** 学生 id(与 studentName 二选一) */
studentId: z.string().min(1).optional(),
classId: z.string().min(1, '请选择班级'),
/** 快捷短语(可选) */
quickPhrase: z.string().trim().max(64).optional(),
/** 自定义消息(可选;与 quickPhrase 至少填一个) */
message: z.string().trim().max(200).optional(),
})
.refine((data) => Boolean(data.studentId), { message: '请选择学生', path: ['studentId'] })
.refine((data) => Boolean(data.message || data.quickPhrase), {
message: '请选择快捷短语或填写自定义消息',
path: ['message'],
});
export type CreateCallInput = z.infer<typeof createCallSchema>;
@@ -0,0 +1,60 @@
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
import { assertClassWritable } from '../../lib/access.js';
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, emitToUser } from '../../realtime/bus.js';
import type { CreateCallInput } from './calls.schemas.js';
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
/**
* "叫人":老师点名让某位同学去找他。
*
* 设计:复用通知表落库(学生端通知中心可见、已读状态天然可用、离线也能看到历史),
* 同时额外广播 `call:new` 到该学生的 user 房间 —— 客户端据此把消息标记为
* "叫人"类型,无论是否在上课都立即展开显示。
*/
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertClassWritable(user, input.classId);
const student = await prisma.user.findUnique({
where: { id: input.studentId },
select: { id: true, name: true, username: true, classId: true },
});
if (!student) throw ApiError.notFound('学生不存在');
if (student.classId !== input.classId) {
throw new ApiError(400, 'BAD_REQUEST', '该学生不在所选班级');
}
const teacher = await prisma.user.findUnique({
where: { id: user.sub },
select: { name: true, username: true },
});
const teacherName = teacher?.name ?? teacher?.username ?? '老师';
const studentName = student.name || student.username;
const message = input.message?.trim() || input.quickPhrase?.trim() || '请尽快到老师这里来';
const created = await prisma.notification.create({
data: {
classId: input.classId,
title: buildCallTitle(studentName, teacherName),
content: message,
// 叫人属于需要立即响应的消息,用 HIGH 级别(不会触发上课时段 409 拦截)
priority: 'HIGH',
createdBy: user.sub,
},
include: { creator: creatorSelect, reads: true },
});
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
// 班级房间广播:通知中心/其他端都能看到
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
// 定向广播给被叫的学生:客户端灵动岛立即展开
emitToUser(student.id, SOCKET_EVENTS.callNew, dto);
logger.info(`叫人:${teacherName} → ${studentName}(${message})`);
return dto;
}
+2
View File
@@ -1,4 +1,5 @@
import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js';
import { coursesModule } from './courses/courses.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
@@ -24,6 +25,7 @@ export const apiModules: ApiModule[] = [
schedulesModule,
homeworksModule,
notificationsModule,
callsModule,
gradesModule,
studentsModule,
teachersModule,
+26 -1
View File
@@ -1,4 +1,4 @@
import type { NotificationPriority, UserRole } from './types.js';
import type { NotificationPriority, UserRole } from './types.js';
/** API 前缀 */
export const API_PREFIX = '/api';
@@ -16,6 +16,8 @@ export const API_PATHS = {
schedules: '/schedules',
homeworks: '/homeworks',
notifications: '/notifications',
/** 叫人:老师点名让学生过来 */
calls: '/calls',
grades: '/grades',
students: '/students',
teachers: '/teachers',
@@ -74,9 +76,32 @@ export const SOCKET_EVENTS = {
gradeUpdated: 'grade:updated',
scheduleUpdated: 'schedule:updated',
classUpdated: 'class:updated',
/** "叫人":老师点名让某位同学去找他(定向到 user:{studentId} 房间) */
callNew: 'call:new',
connected: 'connected',
} as const;
/**
* "叫人"快捷短语(Web 管理端一键选择,学生端灵动岛同步展示)。
* 自定义消息会替换/补全这些短语;`message` 为空时用短语本身。
*/
export const CALL_QUICK_PHRASES: readonly string[] = [
'请到办公室找我',
'请到讲台找我',
'请带上作业本找我',
'请带上试卷找我',
'请到实验室找我',
'请到门卫处找我',
'请到教室门口等我',
'请马上来一趟',
] as const;
/** 叫人消息标题模板:请 XXX 同学找 XXX 老师(老师名字已含"老师"时不重复追加) */
export function buildCallTitle(studentName: string, teacherName: string): string {
const teacher = /老师|教师|主任|校长/.test(teacherName) ? teacherName : `${teacherName} 老师`;
return `请 ${studentName} 同学找 ${teacher}`;
}
/** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */
export const SOCKET_ROOMS = {
class: (classId: string): string => `class:${classId}`,
+29 -2
View File
@@ -254,6 +254,19 @@ export interface CreateNotificationRequest {
confirmDuringClass?: boolean;
}
/**
* 叫人请求:老师在 Web 管理端选中学生 + 快捷短语/自定义消息,
* 学生端灵动岛会立即弹出"请 XXX 同学找 XXX 老师"。
*/
export interface CreateCallRequest {
classId: string;
studentId: string;
/** 快捷短语(与 message 至少填一个) */
quickPhrase?: string;
/** 自定义消息(优先于快捷短语) */
message?: string;
}
export interface NotificationQueryParams {
classId?: string;
priority?: NotificationPriority;
@@ -341,6 +354,14 @@ export interface ClassStatusDto {
/** 灵动岛显示状态 */
export type IslandMode = 'hidden' | 'pill' | 'expanded';
/**
* 灵动岛消息类型:
* - `notification` 普通/重要/紧急通知
* - `homework` 新作业发布(带截止时间,样式区分)
* - `call` "叫人":老师点名让某位同学去找他(无论是否上课都立即展开)
*/
export type IslandNotificationKind = 'notification' | 'homework' | 'call';
/** 投递到灵动岛的通知载荷(服务端 NotificationDto 的精简版) */
export interface IslandNotification {
id: string;
@@ -350,6 +371,10 @@ export interface IslandNotification {
createdAt: string;
courseName?: string | null;
teacherName?: string | null;
/** 消息类型,默认 notification */
kind?: IslandNotificationKind;
/** 附加说明,例如作业截止时间、"请到办公室" */
subtitle?: string | null;
}
/** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */
@@ -363,8 +388,8 @@ export interface IslandState {
inClass: boolean;
/** 当前课的结束时间(HH:mm),用于"下课后自动弹出" */
currentPeriodEnd: string | null;
/** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 */
reason: 'new' | 'after-class' | 'urgent' | null;
/** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 */
reason: 'new' | 'after-class' | 'urgent' | 'call' | null;
updatedAt: number;
}
@@ -390,6 +415,8 @@ export interface DashboardSummary {
/** 服务端 -> 客户端事件名 */
export interface ServerToClientEvents {
'notification:new': (payload: NotificationDto) => void;
/** 叫人:定向广播给被叫学生 */
'call:new': (payload: NotificationDto) => void;
'homework:new': (payload: HomeworkDto) => void;
/** deleted=true 表示该作业已被删除,客户端应从本地缓存中移除 */
'homework:updated': (payload: HomeworkDto & { deleted?: boolean }) => void;
+8
View File
@@ -5,6 +5,7 @@ import {
type ClassStatusDto,
type CourseDto,
type CreateClassRequest,
type CreateCallRequest,
type CreateCourseRequest,
type CreateGradeRequest,
type CreateHomeworkRequest,
@@ -118,6 +119,13 @@ export const notificationApi = {
api.get(`${API_PATHS.notifications}/unread-count`, classId ? { classId } : undefined),
};
/* ------------------------------------------------------------------ 叫人 */
export const callApi = {
/** 点名让学生来找老师(学生端灵动岛会立即弹出"请 XXX 同学找 XXX 老师") */
create: (payload: CreateCallRequest): Promise<NotificationDto> => api.post(API_PATHS.calls, payload),
};
/* ------------------------------------------------------------------ 成绩 */
export const gradeApi = {
+131 -3
View File
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { ROLE_LABELS, formatDate, type ClassDto, type StudentDto } from '@classhelper/shared';
import { classApi, studentApi } from '@/api';
import {
CALL_QUICK_PHRASES,
ROLE_LABELS,
formatDate,
type ClassDto,
type StudentDto,
} from '@classhelper/shared';
import { callApi, classApi, studentApi } from '@/api';
const loading = ref(false);
const students = ref<StudentDto[]>([]);
@@ -100,6 +106,51 @@ async function resetPassword(row: StudentDto): Promise<void> {
onMounted(async () => {
await Promise.all([loadClasses(), loadStudents()]);
});
/* ------------------------------------------------------------ 叫人 */
const callVisible = ref(false);
const callSending = ref(false);
const callTarget = ref<StudentDto | null>(null);
const callForm = reactive({ quickPhrase: '', message: '' });
function openCall(row: StudentDto): void {
callTarget.value = row;
callForm.quickPhrase = CALL_QUICK_PHRASES[0] ?? '';
callForm.message = '';
callVisible.value = true;
}
function pickPhrase(phrase: string): void {
callForm.quickPhrase = callForm.quickPhrase === phrase ? '' : phrase;
}
async function submitCall(): Promise<void> {
const target = callTarget.value;
if (!target) return;
if (!target.classId) {
ElMessage.warning('该学生还没有分班,无法叫人');
return;
}
if (!callForm.message.trim() && !callForm.quickPhrase.trim()) {
ElMessage.warning('请选择快捷短语或填写自定义消息');
return;
}
callSending.value = true;
try {
const created = await callApi.create({
classId: target.classId,
studentId: target.id,
...(callForm.message.trim() ? { message: callForm.message.trim() } : {}),
...(callForm.quickPhrase.trim() ? { quickPhrase: callForm.quickPhrase.trim() } : {}),
});
ElMessage.success(`已通知 ${target.name}:${created.title}`);
callVisible.value = false;
} finally {
callSending.value = false;
}
}
</script>
<template>
@@ -151,8 +202,9 @@ onMounted(async () => {
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="success" @click="openCall(row)">叫人</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" @click="resetPassword(row)">重置密码</el-button>
<el-button link type="danger" @click="removeStudent(row)">删除</el-button>
@@ -161,6 +213,55 @@ onMounted(async () => {
</el-table>
</el-card>
<!-- 叫人:选中学生 + 快捷短语/自定义消息 → 学生端灵动岛立即弹出 -->
<el-dialog v-model="callVisible" title="叫人" width="480px">
<el-alert
v-if="callTarget"
:title="`请 ${callTarget.name} 同学找老师`"
type="success"
:closable="false"
class="call-alert"
>
<template #default>
发送后学生端桌面会立即浮出「请 {{ callTarget.name }} 同学找 XXX 老师」,
无需学生刷新;上课时段也会立刻显示。
</template>
</el-alert>
<div class="call-section">
<div class="call-label">快捷短语(点击选择,再点一次取消)</div>
<div class="call-phrases">
<el-tag
v-for="phrase in CALL_QUICK_PHRASES"
:key="phrase"
class="call-phrase"
:effect="callForm.quickPhrase === phrase ? 'dark' : 'plain'"
:type="callForm.quickPhrase === phrase ? 'success' : 'info'"
@click="pickPhrase(phrase)"
>
{{ phrase }}
</el-tag>
</div>
</div>
<div class="call-section">
<div class="call-label">自定义消息(可选,优先于快捷短语)</div>
<el-input
v-model="callForm.message"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="例如:带上昨天的数学作业到办公室"
/>
</div>
<template #footer>
<el-button @click="callVisible = false">取消</el-button>
<el-button type="primary" :loading="callSending" @click="submitCall">发送叫人</el-button>
</template>
</el-dialog>
<el-dialog v-model="formVisible" :title="editingId ? '编辑学生' : '新建学生'" width="460px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="用户名" prop="username">
@@ -185,3 +286,30 @@ onMounted(async () => {
</el-dialog>
</div>
</template>
<style scoped>
.call-alert {
margin-bottom: 12px;
}
.call-section {
margin-bottom: 14px;
}
.call-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
}
.call-phrases {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.call-phrase {
cursor: pointer;
user-select: none;
}
</style>