Files
ClassHelper/packages/web-admin/src/views/NotificationsView.vue
T

684 lines
25 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, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
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 NotificationCreatorSummary,
type NotificationDaySummary,
type NotificationDto,
type NotificationPriority,
type StudentDto,
} from '@classhelper/shared';
import { callApi, classApi, notificationApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
import { findClassInSession } from '@/composables/useClassInSession';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
const realtime = useRealtimeStore();
const route = useRoute();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const notifications = ref<NotificationDto[]>([]);
const filter = reactive({ classId: '', priority: '', keyword: '', date: '', createdBy: '' });
/**
* 按天归档 / 按发布人归档的候选列表(需求:通知按天归档,也可按发布人、类型看)。
*
* 两者跟着"班级 + 优先级"走:班级一换,日期栏与发布人栏就应该只剩那个班里真实存在的
* 日期与人 —— 否则选项里全是别的班级的数据,选中后得到空列表,看着像坏了。
* 发布人栏还额外跟着当前选中的日期收敛("这一天都有谁发过通知")。
*/
const days = ref<NotificationDaySummary[]>([]);
const creators = ref<NotificationCreatorSummary[]>([]);
let archiveRequest = 0;
let refreshRequest = 0;
let notificationRequest = 0;
async function loadArchive(): Promise<void> {
const request = ++archiveRequest;
const base = {
...(filter.classId ? { classId: filter.classId } : {}),
...(filter.priority ? { priority: filter.priority } : {}),
};
const [dayList, creatorList] = await Promise.all([
notificationApi.days(base).catch(() => ({ days: [] as NotificationDaySummary[] })),
notificationApi
.creators({ ...base, ...(filter.date ? { date: filter.date } : {}) })
.catch(() => ({ creators: [] as NotificationCreatorSummary[] })),
]);
if (request !== archiveRequest) return;
days.value = dayList.days;
creators.value = creatorList.creators;
if (filter.createdBy && !creatorList.creators.some((item) => item.id === filter.createdBy))
filter.createdBy = '';
}
/** 筛选变化后统一刷新:先更新归档候选,再拉当前筛选下的列表 */
async function refreshList(): Promise<void> {
const request = ++refreshRequest;
notificationRequest += 1;
await loadArchive();
if (request !== refreshRequest) return;
await loadNotifications();
}
/**
* 是否"单班视图":选中了具体班级时列表只服务一个班。
* 单班视图只展示"已读情况"(已读 N 人 / 未读),跨班视图才展示已读率百分比。
*/
const isSingleClass = computed(() => Boolean(filter.classId));
/**
* 各班学生数(classId → 人数)。
* NotificationDto 只有 readCount(已读人数),没有 unreadCount / reads,
* 已读率的分母只能自己算,所以进入页面时按班级各取一次学生名单长度。
*/
const classStudentCounts = ref<Record<string, number | null>>({});
function classSizeOf(classId: string): number | null {
return classStudentCounts.value[classId] ?? null;
}
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(() => null);
return [item.id, students?.length ?? null] as const;
}),
);
classStudentCounts.value = Object.fromEntries(entries);
}
async function loadNotifications(): Promise<void> {
const request = ++notificationRequest;
loading.value = true;
try {
const params: {
classId?: string;
priority?: string;
keyword?: string;
date?: string;
createdBy?: string;
} = {};
if (filter.classId) params.classId = filter.classId;
if (filter.priority) params.priority = filter.priority;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
if (filter.date) params.date = filter.date;
if (filter.createdBy) params.createdBy = filter.createdBy;
const list = await notificationApi.list(params);
if (request === notificationRequest) notifications.value = list;
} finally {
if (request === notificationRequest) 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;
}
/**
* 上课时段检查见 `composables/useClassInSession.ts`(大模型助手那条发布路径也用它,
* 判据只留一份,别在这里再写一遍)。
*/
const submitFormSaving = ref(false);
async function submitForm(): Promise<void> {
if (submitFormSaving.value) return;
submitFormSaving.value = true;
try {
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);
} finally {
submitFormSaving.value = false;
}
}
/** 单个班级的发布失败信息(用于汇总提示) */
interface PublishFailure {
classId: string;
reason: string;
/** 服务端因"上课时段紧急通知"拦截(409 URGENT_DURING_CLASS) */
urgentConflict: boolean;
period: ClassPeriod | null;
}
/**
* 真正提交:调用服务端的**批量发布**端点(`POST /api/notifications/bulk`)一次提交,
* 服务端逐班做权限断言与上课保护,并把每个班的结果回给我们。
*
* 为什么不再由前端对每个班各发一次请求:N 个班就是 N 次往返,权限/上课保护
* 分散在 N 个响应里,前端还得自己合并;服务端批量端点把这些收敛成一份逐班结果(见 AGENTS.md §5)。
* confirmDuringClass 为 true 表示教师已在上课警告里确认。
*/
const doPublishSaving = ref(false);
async function doPublish(classIds: string[], confirmDuringClass: boolean): Promise<void> {
if (doPublishSaving.value) return;
doPublishSaving.value = true;
try {
if (classIds.length === 0) return;
const result = await notificationApi.bulkCreate({
classIds,
title: form.title.trim(),
content: form.content,
priority: form.priority,
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
});
const successCount = result.succeeded;
const failures: PublishFailure[] = result.results
.filter((item) => !item.ok)
.map((item) => ({
classId: item.classId,
reason: item.message ?? '发布失败',
urgentConflict: item.code === 'URGENT_DURING_CLASS',
period: ((item.details ?? {}) as { current?: ClassPeriod | null }).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) {
// 有班级发布成功:关掉弹窗并刷新列表;只有"还有班级等着二次确认"时才保留弹窗内的数据。
// 注意这里是 pendingConfirm 而不是 !pendingConfirm —— 写成取反会让弹窗在**发布成功后仍然开着**,
// 老师以为没发出去再点一次「立即发布」,同一批班级就重复收到通知(还会重复广播)。
formVisible.value = pendingConfirm;
await refreshList();
}
} finally {
doPublishSaving.value = false;
}
}
/** 多班发布结果汇总:成功 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();
}
/**
* 从仪表盘点「最新通知」跳过来时带的 `?highlight=<通知 id>`:
* 高亮那一行并滚动过去。之前这个参数没有任何人消费,
* 用户点完只是跳到通知列表,看不出去的是哪一条。
*/
const highlightedId = computed(() => {
const value = route.query.highlight;
return typeof value === 'string' ? value : '';
});
function rowClassName({ row }: { row: NotificationDto }): string {
return row.id === highlightedId.value ? 'row-highlight' : '';
}
onMounted(async () => {
realtime.on(SOCKET_EVENTS.notificationDeleted, onNotificationEvent);
await loadClasses();
await refreshList();
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
// 列表渲染完再滚动,否则表格行还没生成
if (highlightedId.value) {
await nextTick();
document.querySelector('.row-highlight')?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationDeleted, onNotificationEvent);
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),ClassHelper 班级端 5 秒内到达</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="选择班级"
clearable
style="width: 170px"
@change="refreshList"
>
<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="refreshList"
>
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
<!-- 按天归档:只列当前班级/优先级下真实存在的日期与条数 -->
<el-select
v-model="filter.date"
placeholder="按天归档"
clearable
style="width: 170px"
@change="refreshList"
>
<el-option
v-for="item in days"
:key="item.date"
:label="`${item.date}(${item.count})`"
:value="item.date"
/>
</el-select>
<!-- 按发布人归档:跟着班级/优先级/日期收敛 -->
<el-select
v-model="filter.createdBy"
placeholder="全部发布人"
clearable
style="width: 160px"
@change="loadNotifications"
>
<el-option
v-for="item in creators"
:key="item.id"
:label="`${item.name}(${item.count})`"
:value="item.id"
/>
</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="暂无通知"
:row-class-name="rowClassName"
>
<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="所属日期" width="120">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.assignDate }}</el-tag>
</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) === null ? '班级人数暂不可用' : `共 ${classSizeOf(row.classId)} 人`
}}
</div>
</template>
<!-- 跨班视图(未选具体班级):显示已读率百分比,分母按该通知所属班级算 -->
<template v-else-if="classSizeOf(row.classId) !== null">
<el-progress :percentage="readRate(row)" :stroke-width="10" />
<span class="text-muted">{{ row.readCount ?? 0 }}/{{ classSizeOf(row.classId) }} 人</span>
</template>
<span v-else class="text-muted">已读 {{ row.readCount ?? 0 }} 人 · 班级人数暂不可用</span>
</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" :loading="submitFormSaving" @click="submitForm">立即发布</el-button>
</template>
</el-dialog>
<!-- 叫人:选班级 → 选学生 → 快捷短语 / 自定义消息 → ClassHelper 班级端灵动岛立即弹出 -->
<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.studentNo})`"
: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
? '紧急叫人:ClassHelper 班级端桌面无视上课时段立即展开,需学生点「收到」'
: '普通叫人:ClassHelper 班级端课间先显示胶囊、点击展开;上课时段只排队,下课后弹出'
"
/>
<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>
<style scoped>
/* 从仪表盘点「最新通知」跳转过来的定位高亮(?highlight=<通知 id>) */
:deep(.row-highlight) td {
background: var(--el-color-primary-light-9) !important;
}
</style>