feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)
This commit is contained in:
commit
84ab49de10
102 files changed
+14751
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { STORAGE_KEYS, type LoginRequest, type LoginResponse, type StudentDto } from '@classhelper/shared';
|
||||
import { authApi } from '@/api';
|
||||
|
||||
function readStoredUser(): StudentDto | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.user);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as StudentDto;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(STORAGE_KEYS.token));
|
||||
const user = ref<StudentDto | null>(readStoredUser());
|
||||
const loading = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
const role = computed(() => user.value?.role ?? null);
|
||||
const isStaff = computed(() => role.value === 'ADMIN' || role.value === 'TEACHER');
|
||||
const displayName = computed(() => user.value?.name ?? '未登录');
|
||||
const roleLabel = computed(() => {
|
||||
if (role.value === 'ADMIN') return '管理员';
|
||||
if (role.value === 'TEACHER') return '教师';
|
||||
return '学生';
|
||||
});
|
||||
|
||||
function persist(): void {
|
||||
if (token.value) localStorage.setItem(STORAGE_KEYS.token, token.value);
|
||||
else localStorage.removeItem(STORAGE_KEYS.token);
|
||||
if (user.value) localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(user.value));
|
||||
else localStorage.removeItem(STORAGE_KEYS.user);
|
||||
}
|
||||
|
||||
function setSession(payload: LoginResponse): void {
|
||||
token.value = payload.token;
|
||||
user.value = { ...payload.user } as StudentDto;
|
||||
persist();
|
||||
}
|
||||
|
||||
async function login(payload: LoginRequest): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
setSession(await authApi.login(payload));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile(): Promise<void> {
|
||||
if (!token.value) return;
|
||||
user.value = await authApi.me();
|
||||
persist();
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
if (token.value) await authApi.logout();
|
||||
} catch {
|
||||
// 退出登录失败不阻塞本地清理
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
persist();
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isStaff,
|
||||
role,
|
||||
displayName,
|
||||
roleLabel,
|
||||
login,
|
||||
logout,
|
||||
fetchProfile,
|
||||
clear,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user