2026-09-12 15:02:02 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-10-02 00:25:21 +08:00
|
|
|
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
|
|
|
|
|
|
import { useRoute } from 'vue-router';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
|
|
|
|
|
import {
|
2026-09-12 15:02:02 +08:00
|
|
|
|
CALL_QUICK_PHRASES,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
NOTIFICATION_PRIORITIES,
|
|
|
|
|
|
PRIORITY_LABELS,
|
|
|
|
|
|
PRIORITY_TAG_TYPES,
|
|
|
|
|
|
SOCKET_EVENTS,
|
|
|
|
|
|
formatDate,
|
|
|
|
|
|
relativeTime,
|
|
|
|
|
|
truncate,
|
|
|
|
|
|
type ClassDto,
|
2026-09-12 08:41:08 +08:00
|
|
|
|
type ClassPeriod,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
type NotificationDto,
|
|
|
|
|
|
type NotificationPriority,
|
2026-09-12 15:02:02 +08:00
|
|
|
|
type StudentDto,
|
2026-09-11 21:57:38 +08:00
|
|
|
|
} from '@classhelper/shared';
|
2026-09-12 15:02:02 +08:00
|
|
|
|
import { callApi, classApi, notificationApi, scheduleApi } from '@/api';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
import { useRealtimeStore } from '@/stores/realtime';
|
2026-09-12 08:41:08 +08:00
|
|
|
|
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
|
2026-09-11 21:57:38 +08:00
|
|
|
|
|
|
|
|
|
|
const realtime = useRealtimeStore();
|
2026-10-02 00:25:21 +08:00
|
|
|
|
const route = useRoute();
|
2026-09-11 21:57:38 +08:00
|
|
|
|
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const classes = ref<ClassDto[]>([]);
|
|
|
|
|
|
const notifications = ref<NotificationDto[]>([]);
|
|
|
|
|
|
const filter = reactive({ classId: '', priority: '', keyword: '' });
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 是否"单班视图":选中了具体班级时列表只服务一个班。
|
|
|
|
|
|
* 单班视图只展示"已读情况"(已读 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
async function loadClasses(): Promise<void> {
|
|
|
|
|
|
classes.value = await classApi.list();
|
|
|
|
|
|
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
|
2026-09-13 08:39:11 +08:00
|
|
|
|
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);
|
2026-09-11 21:57:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 15:02:02 +08:00
|
|
|
|
/* ------------------------------------------------------------ 叫人(教师可用) */
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 叫人入口放在通知页:班主任与科任老师都能用(学生管理页是管理员专属)。
|
|
|
|
|
|
* 学生名单走 `/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('');
|
2026-09-12 19:54:00 +08:00
|
|
|
|
/** 紧急叫人:无视上课时段立即展开;默认普通(只进队列,课后弹出) */
|
|
|
|
|
|
const callUrgent = ref(false);
|
2026-09-12 15:02:02 +08:00
|
|
|
|
|
|
|
|
|
|
async function openCall(): Promise<void> {
|
|
|
|
|
|
callClassId.value = filter.classId || classes.value[0]?.id || '';
|
|
|
|
|
|
callStudentId.value = '';
|
|
|
|
|
|
callMessage.value = '';
|
|
|
|
|
|
callQuickPhrase.value = CALL_QUICK_PHRASES[0] ?? '';
|
2026-09-12 19:54:00 +08:00
|
|
|
|
callUrgent.value = false;
|
2026-09-12 15:02:02 +08:00
|
|
|
|
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,
|
2026-09-12 19:54:00 +08:00
|
|
|
|
urgent: callUrgent.value,
|
2026-09-12 15:02:02 +08:00
|
|
|
|
...(callMessage.value.trim() ? { message: callMessage.value.trim() } : {}),
|
|
|
|
|
|
...(callQuickPhrase.value.trim() ? { quickPhrase: callQuickPhrase.value.trim() } : {}),
|
|
|
|
|
|
});
|
2026-09-12 19:54:00 +08:00
|
|
|
|
ElMessage.success(`已${callUrgent.value ? '紧急' : ''}通知:${created.title}`);
|
2026-09-12 15:02:02 +08:00
|
|
|
|
callVisible.value = false;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
callSending.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
/* ------------------------------------------------------------ 发布 */
|
|
|
|
|
|
|
|
|
|
|
|
const formVisible = ref(false);
|
|
|
|
|
|
const formRef = ref<FormInstance>();
|
2026-09-13 08:39:11 +08:00
|
|
|
|
const form = reactive<{
|
|
|
|
|
|
classIds: string[];
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
priority: NotificationPriority;
|
|
|
|
|
|
}>({
|
|
|
|
|
|
classIds: [],
|
2026-09-11 21:57:38 +08:00
|
|
|
|
title: '',
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
priority: 'NORMAL',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const rules: FormRules = {
|
2026-09-13 08:39:11 +08:00
|
|
|
|
classIds: [{ required: true, type: 'array', min: 1, message: '请选择目标班级', trigger: 'change' }],
|
2026-09-11 21:57:38 +08:00
|
|
|
|
title: [{ required: true, message: '请输入通知标题', trigger: 'blur' }],
|
|
|
|
|
|
content: [{ required: true, message: '请输入通知内容', trigger: 'blur' }],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
/** 上课时段发布紧急通知的全屏二次确认状态(classIds = 本次要发布的班级) */
|
2026-09-12 08:41:08 +08:00
|
|
|
|
const urgentWarning = reactive({
|
|
|
|
|
|
visible: false,
|
|
|
|
|
|
period: null as ClassPeriod | null,
|
|
|
|
|
|
title: '',
|
2026-09-13 08:39:11 +08:00
|
|
|
|
classIds: [] as string[],
|
2026-09-12 08:41:08 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
function classNameOf(classId: string): string {
|
|
|
|
|
|
return classes.value.find((item) => item.id === classId)?.name ?? classId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 15:02:02 +08:00
|
|
|
|
async function openCreate(): Promise<void> {
|
|
|
|
|
|
// 班级列表可能还没加载完(进入页面后立刻点按钮):先补一次,避免「目标班级」为空导致校验失败
|
|
|
|
|
|
if (classes.value.length === 0) await loadClasses().catch(() => undefined);
|
2026-09-13 08:39:11 +08:00
|
|
|
|
form.classIds = filter.classId ? [filter.classId] : classes.value[0] ? [classes.value[0].id] : [];
|
2026-09-11 21:57:38 +08:00
|
|
|
|
form.title = '';
|
|
|
|
|
|
form.content = '';
|
|
|
|
|
|
form.priority = 'NORMAL';
|
|
|
|
|
|
formVisible.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 上课时段检查:多班发布时逐班查询(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
async function submitForm(): Promise<void> {
|
|
|
|
|
|
const valid = await formRef.value?.validate().catch(() => false);
|
|
|
|
|
|
if (!valid) return;
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
const classIds = [...form.classIds];
|
|
|
|
|
|
if (classIds.length === 0) {
|
|
|
|
|
|
ElMessage.warning('请至少选择一个目标班级');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 上课时段发布紧急通知:先查询所有目标班级的上课状态,任一命中则弹全屏二次确认(3 秒倒计时)
|
2026-09-12 08:41:08 +08:00
|
|
|
|
if (form.priority === 'URGENT') {
|
2026-09-13 08:39:11 +08:00
|
|
|
|
const hit = await findClassInSession(classIds);
|
|
|
|
|
|
if (hit) {
|
|
|
|
|
|
urgentWarning.period = hit.period;
|
2026-09-12 08:41:08 +08:00
|
|
|
|
urgentWarning.title = form.title.trim();
|
2026-09-13 08:39:11 +08:00
|
|
|
|
urgentWarning.classIds = classIds;
|
2026-09-12 08:41:08 +08:00
|
|
|
|
urgentWarning.visible = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
await doPublish(classIds, false);
|
2026-09-12 08:41:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
/** 单个班级的发布失败信息(用于汇总提示) */
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-12 08:41:08 +08:00
|
|
|
|
const response = (
|
2026-09-13 08:39:11 +08:00
|
|
|
|
result.reason as {
|
|
|
|
|
|
response?: {
|
|
|
|
|
|
status?: number;
|
|
|
|
|
|
data?: { code?: string; message?: string; details?: { current?: ClassPeriod | null } };
|
|
|
|
|
|
};
|
2026-09-12 08:41:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
).response;
|
2026-09-13 08:39:11 +08:00
|
|
|
|
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) {
|
2026-10-02 00:25:21 +08:00
|
|
|
|
// 有班级发布成功:关掉弹窗并刷新列表;只有"还有班级等着二次确认"时才保留弹窗内的数据。
|
|
|
|
|
|
// 注意这里是 pendingConfirm 而不是 !pendingConfirm —— 写成取反会让弹窗在**发布成功后仍然开着**,
|
|
|
|
|
|
// 老师以为没发出去再点一次「立即发布」,同一批班级就重复收到通知(还会重复广播与推 ClassIsland)。
|
|
|
|
|
|
formVisible.value = pendingConfirm;
|
2026-09-13 08:39:11 +08:00
|
|
|
|
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;
|
2026-09-12 08:41:08 +08:00
|
|
|
|
}
|
2026-09-13 08:39:11 +08:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-09-12 08:41:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onUrgentConfirmed(): void {
|
2026-09-13 08:39:11 +08:00
|
|
|
|
const classIds = [...urgentWarning.classIds];
|
2026-09-12 08:41:08 +08:00
|
|
|
|
urgentWarning.visible = false;
|
2026-09-13 08:39:11 +08:00
|
|
|
|
urgentWarning.classIds = [];
|
|
|
|
|
|
void doPublish(classIds, true);
|
2026-09-12 08:41:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onUrgentCancelled(): void {
|
|
|
|
|
|
urgentWarning.visible = false;
|
2026-09-13 08:39:11 +08:00
|
|
|
|
urgentWarning.classIds = [];
|
2026-09-12 08:41:08 +08:00
|
|
|
|
ElMessage.info('已取消发布,紧急通知未发出');
|
2026-09-11 21:57:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeNotification(row: NotificationDto): Promise<void> {
|
|
|
|
|
|
await ElMessageBox.confirm(`删除通知「${row.title}」?`, '确认', { type: 'warning' });
|
|
|
|
|
|
await notificationApi.remove(row.id);
|
|
|
|
|
|
ElMessage.success('已删除');
|
|
|
|
|
|
await loadNotifications();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-13 08:39:11 +08:00
|
|
|
|
/** 已读率(跨班视图使用):分母按该通知所属班级的学生数算 */
|
2026-09-11 21:57:38 +08:00
|
|
|
|
function readRate(row: NotificationDto): number {
|
2026-09-13 08:39:11 +08:00
|
|
|
|
const total = classSizeOf(row.classId);
|
|
|
|
|
|
if (!total) return 0;
|
|
|
|
|
|
return Math.round(((row.readCount ?? 0) / total) * 100);
|
2026-09-11 21:57:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onNotificationEvent(): void {
|
|
|
|
|
|
void loadNotifications();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 00:25:21 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从仪表盘点「最新通知」跳过来时带的 `?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' : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 21:57:38 +08:00
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await loadClasses();
|
|
|
|
|
|
await loadNotifications();
|
|
|
|
|
|
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
|
2026-10-02 00:25:21 +08:00
|
|
|
|
// 列表渲染完再滚动,否则表格行还没生成
|
|
|
|
|
|
if (highlightedId.value) {
|
|
|
|
|
|
await nextTick();
|
|
|
|
|
|
document.querySelector('.row-highlight')?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
|
|
|
|
}
|
2026-09-11 21:57:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
realtime.off(SOCKET_EVENTS.notificationNew, onNotificationEvent);
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="page">
|
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 class="page-title">通知发布</h2>
|
2026-10-06 19:41:18 +08:00
|
|
|
|
<p class="page-subtitle">发布后按班级房间广播(notification:new),ClassHelper 班级端 5 秒内到达</p>
|
2026-09-11 21:57:38 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
|
<el-select
|
|
|
|
|
|
v-model="filter.classId"
|
|
|
|
|
|
placeholder="选择班级"
|
2026-09-13 08:39:11 +08:00
|
|
|
|
clearable
|
2026-09-11 21:57:38 +08:00
|
|
|
|
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"
|
|
|
|
|
|
/>
|
2026-09-12 15:02:02 +08:00
|
|
|
|
<el-button type="warning" :icon="'Bell'" @click="openCall">叫人</el-button>
|
2026-09-11 21:57:38 +08:00
|
|
|
|
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布通知</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-12 10:09:11 +08:00
|
|
|
|
<el-card shadow="never" class="table-card">
|
2026-10-02 00:25:21 +08:00
|
|
|
|
<el-table
|
|
|
|
|
|
v-loading="loading"
|
|
|
|
|
|
:data="notifications"
|
|
|
|
|
|
empty-text="暂无通知"
|
|
|
|
|
|
:row-class-name="rowClassName"
|
|
|
|
|
|
>
|
2026-09-11 21:57:38 +08:00
|
|
|
|
<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>
|
2026-09-13 08:39:11 +08:00
|
|
|
|
<el-table-column :label="isSingleClass ? '已读情况' : '已读率'" width="150">
|
2026-09-11 21:57:38 +08:00
|
|
|
|
<template #default="{ row }">
|
2026-09-13 08:39:11 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
单班视图:只显示该通知的已读情况(不显示百分比)。
|
|
|
|
|
|
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>
|
2026-09-11 21:57:38 +08:00
|
|
|
|
</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">
|
2026-09-13 08:39:11 +08:00
|
|
|
|
<el-form-item label="目标班级" prop="classIds">
|
|
|
|
|
|
<el-select
|
|
|
|
|
|
v-model="form.classIds"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
filterable
|
|
|
|
|
|
collapse-tags
|
|
|
|
|
|
collapse-tags-tooltip
|
|
|
|
|
|
placeholder="可多选;发布时逐班提交"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
>
|
2026-09-11 21:57:38 +08:00
|
|
|
|
<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>
|
2026-09-12 08:41:08 +08:00
|
|
|
|
|
2026-10-06 19:41:18 +08:00
|
|
|
|
<!-- 叫人:选班级 → 选学生 → 快捷短语 / 自定义消息 → ClassHelper 班级端灵动岛立即弹出 -->
|
2026-09-12 15:02:02 +08:00
|
|
|
|
<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"
|
2026-10-06 19:41:18 +08:00
|
|
|
|
:label="`${item.name}(${item.studentNo})`"
|
2026-09-12 15:02:02 +08:00
|
|
|
|
: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>
|
2026-09-12 19:54:00 +08:00
|
|
|
|
<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>
|
2026-09-12 15:02:02 +08:00
|
|
|
|
</el-form>
|
|
|
|
|
|
<el-alert
|
2026-09-12 19:54:00 +08:00
|
|
|
|
:type="callUrgent ? 'error' : 'warning'"
|
2026-09-12 15:02:02 +08:00
|
|
|
|
:closable="false"
|
2026-09-12 19:54:00 +08:00
|
|
|
|
:title="
|
|
|
|
|
|
callUrgent
|
2026-10-06 19:41:18 +08:00
|
|
|
|
? '紧急叫人:ClassHelper 班级端桌面无视上课时段立即展开,需学生点「收到」'
|
|
|
|
|
|
: '普通叫人:ClassHelper 班级端课间先显示胶囊、点击展开;上课时段只排队,下课后弹出'
|
2026-09-12 19:54:00 +08:00
|
|
|
|
"
|
2026-09-12 15:02:02 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="callVisible = false">取消</el-button>
|
|
|
|
|
|
<el-button type="warning" :loading="callSending" @click="submitCall">发送叫人</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
2026-09-12 08:41:08 +08:00
|
|
|
|
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) -->
|
|
|
|
|
|
<UrgentClassWarning
|
|
|
|
|
|
:visible="urgentWarning.visible"
|
|
|
|
|
|
:period="urgentWarning.period"
|
|
|
|
|
|
:pending-title="urgentWarning.title"
|
|
|
|
|
|
@cancel="onUrgentCancelled"
|
|
|
|
|
|
@confirm="onUrgentConfirmed"
|
|
|
|
|
|
/>
|
2026-09-11 21:57:38 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-10-02 00:25:21 +08:00
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
/* 从仪表盘点「最新通知」跳转过来的定位高亮(?highlight=<通知 id>) */
|
|
|
|
|
|
:deep(.row-highlight) td {
|
|
|
|
|
|
background: var(--el-color-primary-light-9) !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|