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:
1 parent
f759aac0a2
commit
219f6ae83a
28 files changed
+1555
-156
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user