178 lines
5.4 KiB
TypeScript
178 lines
5.4 KiB
TypeScript
import { ref } from 'vue';
|
||||
|
|
import { defineStore } from 'pinia';
|
|||
|
|
import { ElNotification } from 'element-plus';
|
|||
|
|
import { io, type Socket } from 'socket.io-client';
|
|||
|
|
import {
|
|||
|
|
PRIORITY_LABELS,
|
|||
|
|
SOCKET_EVENTS,
|
|||
|
|
type GradeDto,
|
|||
|
|
type HomeworkDto,
|
|||
|
|
type NotificationDto,
|
|||
|
|
type ScheduleDto,
|
|||
|
|
type ServerToClientEvents,
|
|||
|
|
} from '@classhelper/shared';
|
|||
|
|
import { SOCKET_URL } from '@/config';
|
|||
|
|
|
|||
|
|
type RealtimeEventName = keyof ServerToClientEvents;
|
|||
|
|
type EventHandler = (payload: unknown) => void;
|
|||
|
|
|
|||
|
|
const EVENT_LABELS: Record<string, string> = {
|
|||
|
|
[SOCKET_EVENTS.notificationNew]: '新通知',
|
|||
|
|
[SOCKET_EVENTS.homeworkNew]: '新作业',
|
|||
|
|
[SOCKET_EVENTS.homeworkUpdated]: '作业变更',
|
|||
|
|
[SOCKET_EVENTS.gradeUpdated]: '成绩更新',
|
|||
|
|
[SOCKET_EVENTS.scheduleUpdated]: '课表变更',
|
|||
|
|
[SOCKET_EVENTS.homeworkStatus]: '作业状态',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 实时通道 store:
|
|||
|
|
* - 持有 Socket.IO 连接(自动重连)
|
|||
|
|
* - 收到事件时弹出 Element Plus 通知
|
|||
|
|
* - 通过 on/off 让页面订阅事件后自动刷新列表
|
|||
|
|
*/
|
|||
|
|
export const useRealtimeStore = defineStore('realtime', () => {
|
|||
|
|
const connected = ref(false);
|
|||
|
|
const connecting = ref(false);
|
|||
|
|
const lastEventName = ref<string | null>(null);
|
|||
|
|
const lastEventAt = ref<number | null>(null);
|
|||
|
|
const eventCount = ref(0);
|
|||
|
|
|
|||
|
|
const handlers = new Map<string, Set<EventHandler>>();
|
|||
|
|
let socket: Socket | null = null;
|
|||
|
|
|
|||
|
|
function notify(
|
|||
|
|
event: string,
|
|||
|
|
title: string,
|
|||
|
|
message: string,
|
|||
|
|
type: 'success' | 'info' | 'warning' | 'error' = 'info',
|
|||
|
|
): void {
|
|||
|
|
ElNotification({ title: `${title}`, message, type, duration: 4000, position: 'bottom-right' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function dispatch(event: string, payload: unknown): void {
|
|||
|
|
for (const handler of handlers.get(event) ?? []) {
|
|||
|
|
handler(payload);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bindEvents(instance: Socket): void {
|
|||
|
|
instance.on('connect', () => {
|
|||
|
|
connected.value = true;
|
|||
|
|
connecting.value = false;
|
|||
|
|
});
|
|||
|
|
instance.on('disconnect', () => {
|
|||
|
|
connected.value = false;
|
|||
|
|
});
|
|||
|
|
instance.on('connect_error', (error: Error) => {
|
|||
|
|
connected.value = false;
|
|||
|
|
connecting.value = false;
|
|||
|
|
// 重连过程中的失败只提示一次,避免刷屏
|
|||
|
|
if (eventCount.value === 0) notify('error', '实时通道连接失败', error.message);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
|
|||
|
|
markEvent(SOCKET_EVENTS.notificationNew);
|
|||
|
|
notify(
|
|||
|
|
'info',
|
|||
|
|
`新通知 · ${PRIORITY_LABELS[payload.priority] ?? payload.priority}`,
|
|||
|
|
`${payload.title}`,
|
|||
|
|
payload.priority === 'URGENT' ? 'error' : payload.priority === 'HIGH' ? 'warning' : 'info',
|
|||
|
|
);
|
|||
|
|
dispatch(SOCKET_EVENTS.notificationNew, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => {
|
|||
|
|
markEvent(SOCKET_EVENTS.homeworkNew);
|
|||
|
|
notify('success', '新作业发布', payload.title);
|
|||
|
|
dispatch(SOCKET_EVENTS.homeworkNew, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
instance.on(SOCKET_EVENTS.homeworkUpdated, (payload: HomeworkDto) => {
|
|||
|
|
markEvent(SOCKET_EVENTS.homeworkUpdated);
|
|||
|
|
dispatch(SOCKET_EVENTS.homeworkUpdated, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
instance.on(SOCKET_EVENTS.homeworkStatus, (payload) => {
|
|||
|
|
markEvent(SOCKET_EVENTS.homeworkStatus);
|
|||
|
|
dispatch(SOCKET_EVENTS.homeworkStatus, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => {
|
|||
|
|
markEvent(SOCKET_EVENTS.gradeUpdated);
|
|||
|
|
notify('success', '成绩已更新', `${payload.examName}:${payload.score}/${payload.totalScore}`);
|
|||
|
|
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
instance.on(SOCKET_EVENTS.scheduleUpdated, (payload: { schedule?: ScheduleDto; action: string }) => {
|
|||
|
|
markEvent(SOCKET_EVENTS.scheduleUpdated);
|
|||
|
|
if (payload.action !== 'updated') {
|
|||
|
|
notify(
|
|||
|
|
'info',
|
|||
|
|
'课表变更',
|
|||
|
|
`课表已${payload.action === 'created' ? '新增' : '删除'}:${payload.schedule?.course?.name ?? ''}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
dispatch(SOCKET_EVENTS.scheduleUpdated, payload);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
instance.on(SOCKET_EVENTS.connected, (payload: { rooms: string[] }) => {
|
|||
|
|
markEvent(SOCKET_EVENTS.connected);
|
|||
|
|
console.info('[realtime] 已加入房间', payload.rooms);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function markEvent(event: string): void {
|
|||
|
|
lastEventName.value = event;
|
|||
|
|
lastEventAt.value = Date.now();
|
|||
|
|
eventCount.value += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function connect(token: string): void {
|
|||
|
|
if (socket?.connected || connecting.value) return;
|
|||
|
|
connecting.value = true;
|
|||
|
|
socket = io(SOCKET_URL, {
|
|||
|
|
auth: { token, clientType: 'admin' },
|
|||
|
|
transports: ['websocket', 'polling'],
|
|||
|
|
reconnection: true,
|
|||
|
|
reconnectionDelay: 1000,
|
|||
|
|
reconnectionDelayMax: 8000,
|
|||
|
|
});
|
|||
|
|
bindEvents(socket);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function disconnect(): void {
|
|||
|
|
socket?.disconnect();
|
|||
|
|
socket?.removeAllListeners();
|
|||
|
|
socket = null;
|
|||
|
|
connected.value = false;
|
|||
|
|
connecting.value = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function on(event: RealtimeEventName, handler: EventHandler): void {
|
|||
|
|
const set = handlers.get(event) ?? new Set<EventHandler>();
|
|||
|
|
set.add(handler);
|
|||
|
|
handlers.set(event, set);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function off(event: RealtimeEventName, handler: EventHandler): void {
|
|||
|
|
handlers.get(event)?.delete(handler);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function eventLabel(): string {
|
|||
|
|
return lastEventName.value ? (EVENT_LABELS[lastEventName.value] ?? lastEventName.value) : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
connected,
|
|||
|
|
connecting,
|
|||
|
|
lastEventName,
|
|||
|
|
lastEventAt,
|
|||
|
|
eventCount,
|
|||
|
|
eventLabel,
|
|||
|
|
connect,
|
|||
|
|
disconnect,
|
|||
|
|
on,
|
|||
|
|
off,
|
|||
|
|
};
|
|||
|
|
});
|