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

+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>