Files
ClassHelper/packages/web-admin/src/views/NotificationsView.vue
T
LoveChengke 11008928a1 fix(island,homework,schedule,web): 16 项反馈修复(联合通知点不开 / 设置不生效 / 红点位置 / 看板当天且铺满 / 单双周与周数可调 / Web 手机端与批量通知等)
一、灵动岛
- ①「紧急通知后来了新作业,收起再点开不了新作业」根因有二,都已修:
  (a) active 仍是紧急那条,点击只会反复打开它 —— 现在点击胶囊优先打开**最新**那条未处理消息(队列里更晚的);
  (b) 上课时段 syncWindow 会把普通通知立刻隐藏(表现为点了没反应)—— 新增 explicitShowId,
      "用户主动点开"的那条守卫放行(岛完全隐藏时仍严格不打扰)。
  冒烟新增「紧急通知后来了新作业:收起再点击打开的是新作业(联合通知回归)」。
- ② 设置实时预览/保存:补「设置页真实链路」断言(window.desktop.islandSetAppearance → IPC → 主进程 →
  渲染进程 CSS 变量 + 窗口尺寸),并断言"岛显示中改尺寸立即生效"(实测均通过)。
- ③ 未读红点移到「通知」图标右上角(此前在文字后面),并加 DOM 几何断言(dx>0 且 dy<0,
  没有未读时自动造一条未读通知再断言、断言后删除)。
- 冒烟稳定性:新增 island.setBlurCollapseEnabled()(默认开),测试期间关掉"失焦收起",
  避免系统抢焦点导致展开态被收回的随机失败;专门验证失焦的用例仍临时打开。

二、作业看板(学生端)
- ⑥ 只看当天:新增「只看今天」开关(默认开),按 createdAt 的本地日期过滤,昨天的过期作业不再混入。
- ⑧ 完整显示:卡片同时渲染标题 + 作业要求全文(列表模式也加「作业要求」列)。
- ⑨ 铺满不滚动:看板外层高度固定 + overflow hidden,按内容/可用高度算缩放系数用 transform: scale 适配
  (工具栏显示当前缩放比例),页面本身不出现滚动条。
- ⑦ 全屏看板标题栏用 #header 插槽:标题左对齐、当天日期时间绝对居中(受「显示时间」开关控制,每分钟刷新)。

三、课表与周数
- ⑤ 学期周数可调:新增 Class.termWeeks(默认 20,迁移 + 维护脚本),班主任在班级管理里改;
  GET /api/dashboard/term?classId= 返回该班 maxWeek,课表周次下拉与新建课表的默认 weekEnd 都按它来
  (不再固定 30 周)。
- ⑩ 课表管理只保留「导入 ClassIsland 课程表」入口(时间表由课程表 JSON 的 TimeLayouts 一并带入),
  「导入时间配置」按钮下线;ui-smoke 第 7.7 段改为断言新弹窗(解析 4 行 + 单双周标签 + 旧入口已下线)。

四、以班级为单位(⑬)与 Web 端
- 个人学生账号登录停用:POST /api/auth/login 对 role=STUDENT 返回 403(改用班级码 + 班级密码);
  学生行仍作为个人数据记录键由班级会话代全班读写;e2e 全面切换到班级会话并新增该断言。
- ④ 作业科目过少:新增共享 SUBJECT_CATALOG(16 个常见科目)+ 种子课程扩到全科目;
  Web 作业发布弹窗可选「其他科目(新建)」,提交前自动建课(同名复用)。
- ⑪ 通知支持多班批量发布(多选 + 逐班发布 + 汇总结果 + 二次确认覆盖所有选中班);
  ⑫ 单班只显示已读情况、跨班才显示已读率;⑭ 手机端课表可横向滚动看全 7 天;
  ⑮ 手机端已连接不再显示孤立绿点(仅未连接时显示文字标签);⑯ 手机端「叫人/发布通知」按钮同宽左对齐;
  ⑬ Web 学生管理去掉「用户名」列(新建自动生成学号式用户名,导入仍支持显式指定)。

验证:verify:e2e 149/149、verify:web 20/20、verify:desktop 75/75(复跑 68/68,打包便携版 75/75 packaged=true)、
typecheck / lint / format 全绿;服务端与 Web 已重新打包并部署到本机安装目录。
2026-09-13 08:39:11 +08:00

563 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
CALL_QUICK_PHRASES,
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
SOCKET_EVENTS,
formatDate,
relativeTime,
truncate,
type ClassDto,
type ClassPeriod,
type NotificationDto,
type NotificationPriority,
type StudentDto,
} from '@classhelper/shared';
import { callApi, classApi, notificationApi, scheduleApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
const realtime = useRealtimeStore();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const notifications = ref<NotificationDto[]>([]);
const filter = reactive({ classId: '', priority: '', keyword: '' });
/**
* 是否"单班视图":选中了具体班级时列表只服务一个班。
* 单班视图只展示"已读情况"(已读 N 人 / 未读),跨班视图才展示已读率百分比。
*/
const isSingleClass = computed(() => Boolean(filter.classId));
/**
* 各班学生数(classId → 人数)。
* NotificationDto 只有 readCount(已读人数),没有 unreadCount / reads,
* 已读率的分母只能自己算,所以进入页面时按班级各取一次学生名单长度。
*/
const classStudentCounts = ref<Record<string, number>>({});
function classSizeOf(classId: string): number {
return classStudentCounts.value[classId] ?? 0;
}
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
await loadClassStudentCounts();
}
async function loadClassStudentCounts(): Promise<void> {
const entries = await Promise.all(
classes.value.map(async (item) => {
const students = await classApi.students(item.id).catch(() => []);
return [item.id, students.length] as const;
}),
);
classStudentCounts.value = Object.fromEntries(entries);
}
async function loadNotifications(): Promise<void> {
loading.value = true;
try {
const params: { classId?: string; priority?: string; keyword?: string } = {};
if (filter.classId) params.classId = filter.classId;
if (filter.priority) params.priority = filter.priority;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
notifications.value = await notificationApi.list(params);
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 叫人(教师可用) */
/**
* 叫人入口放在通知页:班主任与科任老师都能用(学生管理页是管理员专属)。
* 学生名单走 `/classes/:id/students`(班级详情接口,教师有权访问),
* 不使用管理员专属的 `/students`。
*/
const callVisible = ref(false);
const callSending = ref(false);
const callClassId = ref('');
const callStudentId = ref('');
const callStudents = ref<StudentDto[]>([]);
const callQuickPhrase = ref<string>(CALL_QUICK_PHRASES[0] ?? '');
const callMessage = ref('');
/** 紧急叫人:无视上课时段立即展开;默认普通(只进队列,课后弹出) */
const callUrgent = ref(false);
async function openCall(): Promise<void> {
callClassId.value = filter.classId || classes.value[0]?.id || '';
callStudentId.value = '';
callMessage.value = '';
callQuickPhrase.value = CALL_QUICK_PHRASES[0] ?? '';
callUrgent.value = false;
callStudents.value = [];
callVisible.value = true;
if (callClassId.value) await loadCallStudents();
}
async function loadCallStudents(): Promise<void> {
callStudentId.value = '';
callStudents.value = callClassId.value ? await classApi.students(callClassId.value) : [];
}
function pickCallPhrase(phrase: string): void {
callQuickPhrase.value = callQuickPhrase.value === phrase ? '' : phrase;
}
async function submitCall(): Promise<void> {
if (!callClassId.value) {
ElMessage.warning('请选择班级');
return;
}
if (!callStudentId.value) {
ElMessage.warning('请选择学生');
return;
}
if (!callMessage.value.trim() && !callQuickPhrase.value.trim()) {
ElMessage.warning('请选择快捷短语或填写自定义消息');
return;
}
callSending.value = true;
try {
const created = await callApi.create({
classId: callClassId.value,
studentId: callStudentId.value,
urgent: callUrgent.value,
...(callMessage.value.trim() ? { message: callMessage.value.trim() } : {}),
...(callQuickPhrase.value.trim() ? { quickPhrase: callQuickPhrase.value.trim() } : {}),
});
ElMessage.success(`已${callUrgent.value ? '紧急' : ''}通知:${created.title}`);
callVisible.value = false;
} finally {
callSending.value = false;
}
}
/* ------------------------------------------------------------ 发布 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const form = reactive<{
classIds: string[];
title: string;
content: string;
priority: NotificationPriority;
}>({
classIds: [],
title: '',
content: '',
priority: 'NORMAL',
});
const rules: FormRules = {
classIds: [{ required: true, type: 'array', min: 1, message: '请选择目标班级', trigger: 'change' }],
title: [{ required: true, message: '请输入通知标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
};
/** 上课时段发布紧急通知的全屏二次确认状态(classIds = 本次要发布的班级) */
const urgentWarning = reactive({
visible: false,
period: null as ClassPeriod | null,
title: '',
classIds: [] as string[],
});
function classNameOf(classId: string): string {
return classes.value.find((item) => item.id === classId)?.name ?? classId;
}
async function openCreate(): Promise<void> {
// 班级列表可能还没加载完(进入页面后立刻点按钮):先补一次,避免「目标班级」为空导致校验失败
if (classes.value.length === 0) await loadClasses().catch(() => undefined);
form.classIds = filter.classId ? [filter.classId] : classes.value[0] ? [classes.value[0].id] : [];
form.title = '';
form.content = '';
form.priority = 'NORMAL';
formVisible.value = true;
}
/**
* 上课时段检查:多班发布时逐班查询(GET /api/schedules/current?classId=...),
* 任一班级正在上课就需要二次确认;返回命中的班级与时段。
*/
async function findClassInSession(
classIds: string[],
): Promise<{ classId: string; period: ClassPeriod | null } | null> {
const results = await Promise.all(
classIds.map(async (classId) => {
const status = await scheduleApi.classStatus(classId).catch(() => null);
return status?.inClass ? { classId, period: status.current } : null;
}),
);
return results.find((item) => item !== null) ?? null;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const classIds = [...form.classIds];
if (classIds.length === 0) {
ElMessage.warning('请至少选择一个目标班级');
return;
}
// 上课时段发布紧急通知:先查询所有目标班级的上课状态,任一命中则弹全屏二次确认(3 秒倒计时)
if (form.priority === 'URGENT') {
const hit = await findClassInSession(classIds);
if (hit) {
urgentWarning.period = hit.period;
urgentWarning.title = form.title.trim();
urgentWarning.classIds = classIds;
urgentWarning.visible = true;
return;
}
}
await doPublish(classIds, false);
}
/** 单个班级的发布失败信息(用于汇总提示) */
interface PublishFailure {
classId: string;
reason: string;
/** 服务端因"上课时段紧急通知"拦截(409 URGENT_DURING_CLASS) */
urgentConflict: boolean;
period: ClassPeriod | null;
}
/**
* 真正提交:对每个班级各调一次 `POST /api/notifications`(Promise.allSettled),
* confirmDuringClass 为 true 表示教师已在上课警告中确认。
*/
async function doPublish(classIds: string[], confirmDuringClass: boolean): Promise<void> {
if (classIds.length === 0) return;
const payload = {
title: form.title.trim(),
content: form.content,
priority: form.priority,
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
};
const settled = await Promise.allSettled(
classIds.map((classId) => notificationApi.create({ ...payload, classId })),
);
let successCount = 0;
const failures: PublishFailure[] = [];
settled.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount += 1;
return;
}
const response = (
result.reason as {
response?: {
status?: number;
data?: { code?: string; message?: string; details?: { current?: ClassPeriod | null } };
};
}
).response;
failures.push({
classId: classIds[index] ?? '',
reason:
response?.data?.message ?? (result.reason instanceof Error ? result.reason.message : '发布失败'),
urgentConflict: response?.status === 409 && response?.data?.code === 'URGENT_DURING_CLASS',
period: response?.data?.details?.current ?? null,
});
});
// 兜底:并发场景下服务端仍可能拦截(例如刚好上课铃响),对这批班级重新走一次二次确认
const conflicted = failures.filter((item) => item.urgentConflict);
const pendingConfirm = !confirmDuringClass && conflicted.length > 0;
if (pendingConfirm) {
urgentWarning.period = conflicted[0]?.period ?? null;
urgentWarning.title = form.title.trim();
urgentWarning.classIds = conflicted.map((item) => item.classId);
urgentWarning.visible = true;
}
const reported = confirmDuringClass ? failures : failures.filter((item) => !item.urgentConflict);
reportPublishResult(successCount, reported);
if (successCount > 0) {
// 有班级发布成功:关掉弹窗并刷新列表(仍需二次确认时保留弹窗内的数据)
formVisible.value = !pendingConfirm;
await loadNotifications();
}
}
/** 多班发布结果汇总:成功 N 个班、失败 M 个班(失败列出班级名 + 原因) */
function reportPublishResult(successCount: number, failures: PublishFailure[]): void {
if (successCount === 0 && failures.length === 0) return;
if (failures.length === 0) {
ElMessage.success(`通知已发布到 ${successCount} 个班级,已推送给学生客户端`);
return;
}
const detail = failures.map((item) => `${classNameOf(item.classId)}(${item.reason})`).join(';');
ElMessage({
type: successCount > 0 ? 'warning' : 'error',
message: `成功 ${successCount} 个班,失败 ${failures.length} 个班|${detail}`,
duration: 6000,
showClose: true,
});
}
function onUrgentConfirmed(): void {
const classIds = [...urgentWarning.classIds];
urgentWarning.visible = false;
urgentWarning.classIds = [];
void doPublish(classIds, true);
}
function onUrgentCancelled(): void {
urgentWarning.visible = false;
urgentWarning.classIds = [];
ElMessage.info('已取消发布,紧急通知未发出');
}
async function removeNotification(row: NotificationDto): Promise<void> {
await ElMessageBox.confirm(`删除通知「${row.title}」?`, '确认', { type: 'warning' });
await notificationApi.remove(row.id);
ElMessage.success('已删除');
await loadNotifications();
}
/** 已读率(跨班视图使用):分母按该通知所属班级的学生数算 */
function readRate(row: NotificationDto): number {
const total = classSizeOf(row.classId);
if (!total) return 0;
return Math.round(((row.readCount ?? 0) / total) * 100);
}
function onNotificationEvent(): void {
void loadNotifications();
}
onMounted(async () => {
await loadClasses();
await loadNotifications();
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotificationEvent);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">通知发布</h2>
<p class="page-subtitle">发布后按班级房间广播(notification:new),学生端 5 秒内到达</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="选择班级"
clearable
style="width: 170px"
@change="loadNotifications"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select
v-model="filter.priority"
placeholder="全部优先级"
clearable
style="width: 150px"
@change="loadNotifications"
>
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
<el-input
v-model="filter.keyword"
placeholder="搜索标题/内容"
clearable
style="width: 170px"
@keyup.enter="loadNotifications"
@clear="loadNotifications"
/>
<el-button type="warning" :icon="'Bell'" @click="openCall">叫人</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布通知</el-button>
</div>
</div>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="notifications" empty-text="暂无通知">
<el-table-column label="优先级" width="90">
<template #default="{ row }">
<el-tag
:type="PRIORITY_TAG_TYPES[row.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[row.priority as NotificationPriority] }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="内容摘要" min-width="220">
<template #default="{ row }">{{ truncate(row.content, 40) }}</template>
</el-table-column>
<el-table-column label="发布人" width="110">
<template #default="{ row }">{{ row.creator?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
<template #default="{ row }">
<div>{{ formatDate(row.createdAt, true) }}</div>
<div class="text-muted">{{ relativeTime(row.createdAt) }}</div>
</template>
</el-table-column>
<el-table-column :label="isSingleClass ? '已读情况' : '已读率'" width="150">
<template #default="{ row }">
<!--
单班视图:只显示该通知的已读情况(不显示百分比)。
NotificationDto 只有 readCount(已读人数),没有 unreadCount / reads 明细,
所以这里用「已读 N 人 / 未读」标签 + 班级总人数表达已读情况。
-->
<template v-if="isSingleClass">
<el-tag :type="(row.readCount ?? 0) > 0 ? 'success' : 'info'" size="small" effect="light">
{{ (row.readCount ?? 0) > 0 ? `已读 ${row.readCount} 人` : '未读' }}
</el-tag>
<div class="text-muted">共 {{ classSizeOf(row.classId) }} 人</div>
</template>
<!-- 跨班视图(未选具体班级):显示已读率百分比,分母按该通知所属班级算 -->
<template v-else>
<el-progress :percentage="readRate(row)" :stroke-width="10" />
<span class="text-muted">{{ row.readCount ?? 0 }}/{{ classSizeOf(row.classId) }} 人</span>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeNotification(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" title="发布通知" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="目标班级" prop="classIds">
<el-select
v-model="form.classIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="可多选;发布时逐班提交"
style="width: 100%"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-radio-group v-model="form.priority">
<el-radio-button v-for="item in NOTIFICATION_PRIORITIES" :key="item" :value="item">
{{ PRIORITY_LABELS[item] }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="5" maxlength="5000" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">立即发布</el-button>
</template>
</el-dialog>
<!-- 叫人:选班级 → 选学生 → 快捷短语 / 自定义消息 → 学生端灵动岛立即弹出 -->
<el-dialog v-model="callVisible" title="叫人" width="520px">
<el-form label-width="76px">
<el-form-item label="班级">
<el-select v-model="callClassId" style="width: 100%" @change="loadCallStudents">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="学生">
<el-select v-model="callStudentId" filterable placeholder="选择学生" style="width: 100%">
<el-option
v-for="item in callStudents"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="快捷短语">
<div class="call-phrases">
<el-tag
v-for="phrase in CALL_QUICK_PHRASES"
:key="phrase"
class="call-phrase"
:effect="callQuickPhrase === phrase ? 'dark' : 'plain'"
:type="callQuickPhrase === phrase ? 'warning' : 'info'"
@click="pickCallPhrase(phrase)"
>
{{ phrase }}
</el-tag>
</div>
</el-form-item>
<el-form-item label="自定义">
<el-input
v-model="callMessage"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="可选;填写后优先于快捷短语"
/>
</el-form-item>
<el-form-item label="级别">
<el-radio-group v-model="callUrgent">
<el-radio-button :value="false">普通(不打断课堂)</el-radio-button>
<el-radio-button :value="true">紧急(立即展开)</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<el-alert
:type="callUrgent ? 'error' : 'warning'"
:closable="false"
:title="
callUrgent
? '紧急叫人:学生端桌面无视上课时段立即展开,需学生点「收到」'
: '普通叫人:学生端课间先显示胶囊、点击展开;上课时段只排队,下课后弹出'
"
/>
<template #footer>
<el-button @click="callVisible = false">取消</el-button>
<el-button type="warning" :loading="callSending" @click="submitCall">发送叫人</el-button>
</template>
</el-dialog>
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) -->
<UrgentClassWarning
:visible="urgentWarning.visible"
:period="urgentWarning.period"
:pending-title="urgentWarning.title"
@cancel="onUrgentCancelled"
@confirm="onUrgentConfirmed"
/>
</div>
</template>