feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)

This commit is contained in:
LoveChengke committed 2026-09-11 21:57:38 +08:00
commit 84ab49de10
102 files changed
+14751

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import type { NotificationPriority, UserRole } from './types.js';
/** API 前缀 */
export const API_PREFIX = '/api';
/** REST 路径集中管理,避免前端散落硬编码字符串 */
export const API_PATHS = {
auth: {
login: '/auth/login',
logout: '/auth/logout',
me: '/auth/me',
changePassword: '/auth/password',
},
classes: '/classes',
courses: '/courses',
schedules: '/schedules',
homeworks: '/homeworks',
notifications: '/notifications',
grades: '/grades',
students: '/students',
teachers: '/teachers',
dashboard: '/dashboard/summary',
} as const;
/** 用户角色 */
export const USER_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER', 'STUDENT'];
export const ROLE_LABELS: Record<UserRole, string> = {
ADMIN: '系统管理员',
TEACHER: '教师',
STUDENT: '学生',
};
/** 通知优先级 */
export const NOTIFICATION_PRIORITIES: readonly NotificationPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT'];
export const PRIORITY_LABELS: Record<NotificationPriority, string> = {
LOW: '低',
NORMAL: '普通',
HIGH: '重要',
URGENT: '紧急',
};
/** Element Plus tag 类型,三端统一文案与配色 */
export const PRIORITY_TAG_TYPES: Record<NotificationPriority, 'info' | 'primary' | 'warning' | 'danger'> = {
LOW: 'info',
NORMAL: 'primary',
HIGH: 'warning',
URGENT: 'danger',
};
/** 星期:1=周一 ... 7=周日(与数据库 dayOfWeek 一致) */
export const WEEKDAY_LABELS: Record<number, string> = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
};
export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
/** 默认学期周次上限 */
export const DEFAULT_WEEK_COUNT = 20;
/** WebSocket 事件名(服务端广播 / 客户端监听保持一致) */
export const SOCKET_EVENTS = {
notificationNew: 'notification:new',
homeworkNew: 'homework:new',
homeworkUpdated: 'homework:updated',
homeworkStatus: 'homework:status',
gradeUpdated: 'grade:updated',
scheduleUpdated: 'schedule:updated',
classUpdated: 'class:updated',
connected: 'connected',
} as const;
/** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */
export const SOCKET_ROOMS = {
class: (classId: string): string => `class:${classId}`,
user: (userId: string): string => `user:${userId}`,
teacher: (teacherId: string): string => `teacher:${teacherId}`,
role: (role: UserRole): string => `role:${role}`,
students: 'students',
teachers: 'teachers',
} as const;
/** 本地持久化键(Web 端 localStorage / EXE 端 IndexedDB 命名空间) */
export const STORAGE_KEYS = {
token: 'classhelper.token',
user: 'classhelper.user',
serverUrl: 'classhelper.serverUrl',
cachePrefix: 'classhelper.cache',
lastSyncAt: 'classhelper.lastSyncAt',
} as const;
/** 桌面客户端默认后端地址 */
export const DEFAULT_SERVER_URL = 'http://127.0.0.1:4000';
/** 默认端口与分页 */
export const DEFAULT_SERVER_PORT = 4000;
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 200;
/** 教师与管理员角色集合,便于 RBAC 判断 */
export const STAFF_ROLES: readonly UserRole[] = ['ADMIN', 'TEACHER'];
+7
View File
@@ -0,0 +1,7 @@
/**
* @classhelper/shared 入口。
* 后端、Web 管理端、桌面客户端统一从这里导入类型与工具函数。
*/
export * from './constants.js';
export * from './utils.js';
export type * from './types.js';
+350
View File
@@ -0,0 +1,350 @@
/**
* 三端共享的领域类型定义。
* 这里的类型是后端 REST API / WebSocket 契约的唯一来源,
* 后端返回值与前端调用方都必须依赖它,避免契约漂移。
*/
/** 用户角色:管理员 / 教师 / 学生 */
export type UserRole = 'ADMIN' | 'TEACHER' | 'STUDENT';
/** 通知优先级 */
export type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
/** 成绩等级 */
export type GradeLevel = 'A' | 'B' | 'C' | 'D' | 'E';
/** 统一响应体(后端所有接口均返回该结构) */
export interface ApiResponse<T = unknown> {
success: boolean;
data: T;
message: string;
}
/** 统一错误响应体 */
export interface ApiErrorResponse {
success: false;
data: null;
message: string;
code?: string;
details?: unknown;
}
/** 分页查询参数 */
export interface PaginationQuery {
page?: number;
pageSize?: number;
}
/** 分页结果 */
export interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
/* ------------------------------------------------------------------ 用户 / 认证 */
export interface UserDto {
id: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
createdAt: string;
}
export interface StudentDto extends UserDto {
className?: string | null;
grade?: string | null;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
user: UserDto;
}
/* ------------------------------------------------------------------ 班级 */
export interface ClassDto {
id: string;
name: string;
grade: string;
teacherId: string;
createdAt: string;
teacher?: ClassTeacherBrief | null;
studentCount?: number;
courseCount?: number;
homeworkCount?: number;
notificationCount?: number;
}
export interface ClassTeacherBrief {
id: string;
name: string;
username: string;
}
export interface ClassDetailDto extends ClassDto {
students: StudentDto[];
courses: CourseDto[];
teachers: ClassTeacherBrief[];
}
export interface CreateClassRequest {
name: string;
grade: string;
}
export interface UpdateClassRequest {
name?: string;
grade?: string;
}
/* ------------------------------------------------------------------ 课程 */
export interface CourseDto {
id: string;
name: string;
teacherId: string;
classId: string;
createdAt: string;
teacher?: ClassTeacherBrief | null;
}
export interface CreateCourseRequest {
name: string;
classId: string;
}
/* ------------------------------------------------------------------ 课表 */
/**
* 课表条目。
* 星期与周次均为 1 起始:dayOfWeek 1=周一 ... 7=周日,weekStart/weekEnd 为学期周次。
*/
export interface ScheduleDto {
id: string;
classId: string;
courseId: string;
dayOfWeek: number;
/** HH:mm */
startTime: string;
/** HH:mm */
endTime: string;
location: string | null;
weekStart: number;
weekEnd: number;
createdAt: string;
course?: CourseBrief | null;
}
export interface CourseBrief {
id: string;
name: string;
}
export interface CreateScheduleRequest {
classId: string;
courseId: string;
dayOfWeek: number;
startTime: string;
endTime: string;
location?: string | null;
weekStart?: number;
weekEnd?: number;
}
export type UpdateScheduleRequest = Partial<CreateScheduleRequest>;
/** 按天分组的课表视图,供客户端渲染周视图 */
export interface ScheduleDayColumn {
dayOfWeek: number;
label: string;
items: ScheduleDto[];
}
export interface ScheduleWeekView {
week: number;
columns: ScheduleDayColumn[];
}
/* ------------------------------------------------------------------ 作业 */
export interface HomeworkDto {
id: string;
classId: string;
courseId: string | null;
title: string;
content: string;
attachmentUrl: string | null;
dueAt: string | null;
createdBy: string;
createdAt: string;
course?: CourseBrief | null;
creator?: ClassTeacherBrief | null;
/** 当前登录学生的完成状态(学生端接口返回) */
completed?: boolean;
homeworkStatus?: HomeworkStatusDto | null;
/** 教师视角:已提交人数 */
completedCount?: number;
}
export interface HomeworkStatusDto {
id: string;
homeworkId: string;
userId: string;
completed: boolean;
updatedAt: string;
}
export interface CreateHomeworkRequest {
classId: string;
courseId?: string | null;
title: string;
content: string;
attachmentUrl?: string | null;
dueAt?: string | null;
}
export interface UpdateHomeworkStatusRequest {
completed: boolean;
}
export interface HomeworkQueryParams {
classId?: string;
courseId?: string;
/** 学生端:只看未完成 */
pendingOnly?: boolean;
keyword?: string;
}
/* ------------------------------------------------------------------ 通知 */
export interface NotificationDto {
id: string;
classId: string;
title: string;
content: string;
priority: NotificationPriority;
createdBy: string;
createdAt: string;
creator?: ClassTeacherBrief | null;
/** 当前用户是否已读 */
read?: boolean;
readAt?: string | null;
/** 教师视角:已读人数 */
readCount?: number;
}
export interface CreateNotificationRequest {
classId: string;
title: string;
content: string;
priority?: NotificationPriority;
}
export interface NotificationQueryParams {
classId?: string;
priority?: NotificationPriority;
unreadOnly?: boolean;
keyword?: string;
}
/* ------------------------------------------------------------------ 成绩 */
export interface GradeDto {
id: string;
classId: string;
courseId: string | null;
userId: string;
examName: string;
score: number;
totalScore: number;
publishedAt: string;
course?: CourseBrief | null;
student?: ClassTeacherBrief | null;
className?: string | null;
}
export interface CreateGradeRequest {
classId: string;
courseId?: string | null;
userId: string;
examName: string;
score: number;
totalScore?: number;
publishedAt?: string | null;
}
/** 批量导入成绩(教师端"导入成绩"使用) */
export interface BulkCreateGradeRequest {
classId: string;
courseId?: string | null;
examName: string;
totalScore?: number;
publishedAt?: string | null;
items: Array<{ userId: string; score: number }>;
}
/** 成绩统计(教师端图表 / 学生端汇总) */
export interface GradeStats {
total: number;
averagePercent: number;
byCourse: Array<{ courseId: string | null; courseName: string; count: number; averagePercent: number }>;
distribution: Array<{ level: GradeLevel; count: number }>;
}
/* ------------------------------------------------------------------ 仪表盘 */
export interface DashboardSummary {
classCount: number;
studentCount: number;
courseCount: number;
scheduleCount: number;
homeworkCount: number;
notificationCount: number;
gradeCount: number;
unreadNotificationCount: number;
pendingHomeworkCount: number;
recentNotifications: NotificationDto[];
recentHomeworks: HomeworkDto[];
upcomingDeadlines: HomeworkDto[];
}
/* ------------------------------------------------------------------ WebSocket */
/** 服务端 -> 客户端事件名 */
export interface ServerToClientEvents {
'notification:new': (payload: NotificationDto) => void;
'homework:new': (payload: HomeworkDto) => void;
/** deleted=true 表示该作业已被删除,客户端应从本地缓存中移除 */
'homework:updated': (payload: HomeworkDto & { deleted?: boolean }) => void;
'homework:status': (payload: HomeworkStatusDto & { classId: string }) => void;
'grade:updated': (payload: GradeDto) => void;
'schedule:updated': (payload: {
classId: string;
action: 'created' | 'updated' | 'deleted';
schedule?: ScheduleDto;
}) => void;
'class:updated': (payload: { classId: string; action: 'created' | 'updated' | 'deleted' }) => void;
connected: (payload: { userId: string; role: UserRole; rooms: string[] }) => void;
}
/** 客户端 -> 服务端事件名 */
export interface ClientToServerEvents {
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void;
'class:leave': (classId: string) => void;
ping: (ack?: () => void) => void;
}
/** 连接鉴权信息(Socket.IO handshake.auth) */
export interface SocketAuthPayload {
token: string;
clientType?: 'admin' | 'desktop';
}
+209
View File
@@ -0,0 +1,209 @@
import { WEEKDAY_LABELS, WEEKDAYS } from './constants.js';
import type { GradeLevel, ScheduleDto, ScheduleWeekView } from './types.js';
/* ------------------------------------------------------------------ 日期时间 */
export function toDate(value: string | number | Date): Date {
return value instanceof Date ? value : new Date(value);
}
function pad(input: number): string {
return input < 10 ? `0${input}` : String(input);
}
/** 格式化为 YYYY-MM-DD,withTime=true 时追加 HH:mm */
export function formatDate(value: string | number | Date | null | undefined, withTime = false): string {
if (value === null || value === undefined || value === '') return '';
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return withTime ? `${day} ${pad(date.getHours())}:${pad(date.getMinutes())}` : day;
}
export function formatTime(value: string | number | Date | null | undefined): string {
if (value === null || value === undefined || value === '') return '';
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** 周几,1=周一 ... 7=周日 */
export function weekdayOf(value: string | number | Date): number {
const day = toDate(value).getDay();
return day === 0 ? 7 : day;
}
/** ISO 周次(与学期周次解耦,仅用于展示当前自然周) */
export function getIsoWeek(value: string | number | Date): number {
const date = toDate(value);
const target = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNumber = target.getUTCDay() || 7;
target.setUTCDate(target.getUTCDate() + 4 - dayNumber);
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
return Math.ceil(((target.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/** 相对时间描述,例如"3 分钟前" */
export function relativeTime(value: string | number | Date, now: Date = new Date()): string {
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
const diffSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffSeconds < 60) return '刚刚';
const minutes = Math.floor(diffSeconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} 天前`;
return formatDate(date);
}
/* ------------------------------------------------------------------ 课表 */
/** 判断某个课表条目在第 week 周是否上课 */
export function isScheduleActiveInWeek(
schedule: Pick<ScheduleDto, 'weekStart' | 'weekEnd'>,
week: number,
): boolean {
return week >= schedule.weekStart && week <= schedule.weekEnd;
}
/** 格式化周次范围,例如"1-20 周" / "单周" */
export function formatWeekRange(weekStart: number, weekEnd: number): string {
if (weekStart === weekEnd) return `第 ${weekStart} 周`;
return `${weekStart}-${weekEnd} 周`;
}
/** 按时间排序(HH:mm 字符串可直接字典序比较) */
export function sortScheduleByTime<T extends Pick<ScheduleDto, 'dayOfWeek' | 'startTime'>>(items: T[]): T[] {
return [...items].sort((a, b) =>
a.dayOfWeek === b.dayOfWeek ? a.startTime.localeCompare(b.startTime) : a.dayOfWeek - b.dayOfWeek,
);
}
/** 组装周视图:7 列固定输出,便于前端直接渲染 */
export function buildScheduleWeekView(items: ScheduleDto[], week: number): ScheduleWeekView {
const active = sortScheduleByTime(items.filter((item) => isScheduleActiveInWeek(item, week)));
return {
week,
columns: WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
label: WEEKDAY_LABELS[dayOfWeek] ?? `周${dayOfWeek}`,
items: active.filter((item) => item.dayOfWeek === dayOfWeek),
})),
};
}
/** 计算两个 HH:mm 之间的时长(分钟),非法输入返回 0 */
export function durationMinutes(startTime: string, endTime: string): number {
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
if ([sh, sm, eh, em].some((n) => Number.isNaN(n))) return 0;
return (eh as number) * 60 + (em as number) - ((sh as number) * 60 + (sm as number));
}
/* ------------------------------------------------------------------ 作业 */
/** 是否已过截止时间 */
export function isOverdue(dueAt: string | null | undefined, now: Date = new Date()): boolean {
if (!dueAt) return false;
const due = toDate(dueAt);
return !Number.isNaN(due.getTime()) && due.getTime() < now.getTime();
}
/** 距离截止时间的可读描述,例如"还剩 2 天"、"已逾期 3 小时" */
export function formatDeadline(dueAt: string | null | undefined, now: Date = new Date()): string {
if (!dueAt) return '无截止时间';
const due = toDate(dueAt);
if (Number.isNaN(due.getTime())) return '无截止时间';
const diffMinutes = Math.round((due.getTime() - now.getTime()) / 60000);
const prefix = diffMinutes < 0 ? '已逾期' : '还剩';
const abs = Math.abs(diffMinutes);
if (abs < 60) return `${prefix} ${abs} 分钟`;
if (abs < 60 * 24) return `${prefix} ${Math.floor(abs / 60)} 小时`;
return `${prefix} ${Math.floor(abs / (60 * 24))} 天`;
}
/* ------------------------------------------------------------------ 成绩 */
/** 得分率(0-100),总分非法时返回 0 */
export function gradePercent(score: number, totalScore: number): number {
if (!Number.isFinite(score) || !Number.isFinite(totalScore) || totalScore <= 0) return 0;
return round((score / totalScore) * 100, 1);
}
/** 等级换算,学生端图表与标签复用 */
export function gradeLevel(percent: number): GradeLevel {
if (percent >= 90) return 'A';
if (percent >= 80) return 'B';
if (percent >= 70) return 'C';
if (percent >= 60) return 'D';
return 'E';
}
export function averageOf(numbers: number[]): number {
const valid = numbers.filter((n) => Number.isFinite(n));
if (valid.length === 0) return 0;
return round(valid.reduce((sum, n) => sum + n, 0) / valid.length, 1);
}
export function round(value: number, digits = 2): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
/* ------------------------------------------------------------------ 通用 */
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function groupBy<T, K extends string | number>(items: T[], keyOf: (item: T) => K): Map<K, T[]> {
const map = new Map<K, T[]>();
for (const item of items) {
const key = keyOf(item);
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
return map;
}
export function uniqueBy<T, K>(items: T[], keyOf: (item: T) => K): T[] {
const seen = new Set<K>();
const result: T[] = [];
for (const item of items) {
const key = keyOf(item);
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
/** 生成 [1, max] 的周次选项,课表周次切换与表单下拉复用 */
export function buildWeekOptions(max = 20, min = 1): number[] {
const safeMax = Math.max(min, Math.floor(max));
return Array.from({ length: safeMax - min + 1 }, (_, index) => min + index);
}
/** 安全的数字转换,表单输入(字符串)到接口字段(number)之间使用 */
export function toNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
/** 截断长文本(列表页展示摘要) */
export function truncate(text: string | null | undefined, max = 60): string {
if (!text) return '';
return text.length <= max ? text : `${text.slice(0, max)}…`;
}
/** HH:mm 校验 */
export function isValidTimeString(value: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
}