fix(grades,term): 封闭实时成绩泄露并完善并发升级审计
This commit is contained in:
1 parent
b990701cde
commit
57069a9a75
8 files changed
+152
-57
No files matched your search
@@ -23,7 +23,7 @@ export async function loadPlans(classId: string) {
|
|||||||
export function projectPlan(plan: PlanRow): ScheduleDto[] {
|
export function projectPlan(plan: PlanRow): ScheduleDto[] {
|
||||||
return plan.entries
|
return plan.entries
|
||||||
.filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON')
|
.filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON')
|
||||||
.map((e) => ({
|
.map<ScheduleDto>((e) => ({
|
||||||
id: e.legacyScheduleId || e.id,
|
id: e.legacyScheduleId || e.id,
|
||||||
classId: plan.classId,
|
classId: plan.classId,
|
||||||
courseId: e.courseId!,
|
courseId: e.courseId!,
|
||||||
@@ -43,7 +43,8 @@ export function projectPlan(plan: PlanRow): ScheduleDto[] {
|
|||||||
source: 'manual',
|
source: 'manual',
|
||||||
createdAt: plan.createdAt.toISOString(),
|
createdAt: plan.createdAt.toISOString(),
|
||||||
updatedAt: plan.updatedAt.toISOString(),
|
updatedAt: plan.updatedAt.toISOString(),
|
||||||
}));
|
}))
|
||||||
|
.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { activeStudentWhere } from '../../lib/students.js';
|
||||||
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
|
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
|
||||||
import {
|
import {
|
||||||
assertClassAccess,
|
assertClassAccess,
|
||||||
@@ -64,12 +65,12 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
|
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
|
||||||
prisma.class.count({ where: classWhere }),
|
prisma.class.count({ where: classWhere }),
|
||||||
prisma.student.count({ where: { ...where, status: 'active', archivedYearId: null } }),
|
prisma.student.count({ where: { ...where, ...activeStudentWhere(), archivedYearId: null } }),
|
||||||
prisma.course.count({ where }),
|
prisma.course.count({ where }),
|
||||||
prisma.classPlan.count({ where }),
|
prisma.classPlan.count({ where }),
|
||||||
prisma.homework.count({ where }),
|
prisma.homework.count({ where }),
|
||||||
prisma.notification.count({ where }),
|
prisma.notification.count({ where }),
|
||||||
prisma.grade.count({ where }),
|
prisma.grade.count({ where: { ...where, student: activeStudentWhere() } }),
|
||||||
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
|
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
|
||||||
device
|
device
|
||||||
? prisma.homework.count({
|
? prisma.homework.count({
|
||||||
@@ -87,7 +88,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
|||||||
include: {
|
include: {
|
||||||
course: courseSelect,
|
course: courseSelect,
|
||||||
creator: creatorSelect,
|
creator: creatorSelect,
|
||||||
statuses: device ? { where: personalWhere } : true,
|
statuses: { where: device ? personalWhere : { student: activeStudentWhere() } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 5,
|
take: 5,
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ import type {
|
|||||||
UpdateGradeLevelsInput,
|
UpdateGradeLevelsInput,
|
||||||
} from './grades.schemas.js';
|
} from './grades.schemas.js';
|
||||||
|
|
||||||
|
/** 个人查询关闭时,实时通道同样只发送刷新通知,不能绕过 HTTP 明细闸门。 */
|
||||||
|
async function emitGradeUpdated(classId: string, payload: unknown) {
|
||||||
|
const cls = await prisma.class.findUnique({
|
||||||
|
where: { id: classId },
|
||||||
|
select: { studentGradeQueryEnabled: true },
|
||||||
|
});
|
||||||
|
emitToClass(
|
||||||
|
classId,
|
||||||
|
SOCKET_EVENTS.gradeUpdated,
|
||||||
|
cls?.studentGradeQueryEnabled ? payload : { classId, collective: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const courseSelect = { select: { id: true, name: true } } as const;
|
const courseSelect = { select: { id: true, name: true } } as const;
|
||||||
const studentSelect = { select: { id: true, studentNo: true, name: true } } as const;
|
const studentSelect = { select: { id: true, studentNo: true, name: true } } as const;
|
||||||
const classSelect = { select: { name: true } } as const;
|
const classSelect = { select: { name: true } } as const;
|
||||||
@@ -230,7 +243,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput):
|
|||||||
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
|
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
|
await emitGradeUpdated(dto.classId, dto);
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +274,7 @@ export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGrad
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (results[0]) {
|
if (results[0]) {
|
||||||
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
|
await emitGradeUpdated(input.classId, {
|
||||||
...results[0],
|
...results[0],
|
||||||
bulk: true,
|
bulk: true,
|
||||||
count: results.length,
|
count: results.length,
|
||||||
@@ -311,7 +324,7 @@ export async function updateGrade(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dto = toGradeDto(updated);
|
const dto = toGradeDto(updated);
|
||||||
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
|
await emitGradeUpdated(dto.classId, dto);
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +334,7 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
|
|||||||
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
|
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
|
||||||
|
|
||||||
await prisma.grade.delete({ where: { id: gradeId } });
|
await prisma.grade.delete({ where: { id: gradeId } });
|
||||||
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
|
await emitGradeUpdated(current.classId, {
|
||||||
...toGradeDto({ ...current, course: null, student: null, class: null }),
|
...toGradeDto({ ...current, course: null, student: null, class: null }),
|
||||||
deleted: true,
|
deleted: true,
|
||||||
});
|
});
|
||||||
@@ -368,8 +381,7 @@ export async function updateGradeLevels(
|
|||||||
const classId = byId.get(item.id)!.classId;
|
const classId = byId.get(item.id)!.classId;
|
||||||
counts.set(classId, (counts.get(classId) ?? 0) + 1);
|
counts.set(classId, (counts.get(classId) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
for (const [classId, count] of counts)
|
for (const [classId, count] of counts) await emitGradeUpdated(classId, { bulk: true, count });
|
||||||
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count });
|
|
||||||
return { updated: input.items.length };
|
return { updated: input.items.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +412,7 @@ export async function updateGradeLevels(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: records.length });
|
await emitGradeUpdated(classId, { bulk: true, count: records.length });
|
||||||
return { updated: records.length };
|
return { updated: records.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,23 +151,33 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re
|
|||||||
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
|
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
|
||||||
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
|
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
|
||||||
*/
|
*/
|
||||||
router.post('/time-layout', requirePermission('timetable:manage'), validate({ body: timeLayoutImportSchema }), async (req, res) => {
|
router.post(
|
||||||
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
|
'/time-layout',
|
||||||
sendOk(
|
requirePermission('timetable:manage'),
|
||||||
res,
|
validate({ body: timeLayoutImportSchema }),
|
||||||
result,
|
async (req, res) => {
|
||||||
result.mode === 'merge'
|
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
|
||||||
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节`
|
sendOk(
|
||||||
: `覆盖导入成功:共 ${result.replaced} 节`,
|
res,
|
||||||
);
|
result,
|
||||||
});
|
result.mode === 'merge'
|
||||||
|
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节`
|
||||||
|
: `覆盖导入成功:共 ${result.replaced} 节`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
|
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
|
||||||
router.delete('/time-layout/:id', requirePermission('timetable:manage'), validate({ params: idParamSchema }), async (req, res) => {
|
router.delete(
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
'/time-layout/:id',
|
||||||
await deleteTimeLayout(getAuthUser(req), id);
|
requirePermission('timetable:manage'),
|
||||||
sendOk(res, { id }, '时间配置已删除');
|
validate({ params: idParamSchema }),
|
||||||
});
|
async (req, res) => {
|
||||||
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
|
await deleteTimeLayout(getAuthUser(req), id);
|
||||||
|
sendOk(res, { id }, '时间配置已删除');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
|
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
|
||||||
|
|||||||
@@ -340,22 +340,55 @@ export async function importTimeLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = input.name?.trim() || '默认时间表';
|
const name = input.name?.trim() || '默认时间表';
|
||||||
const current = await prisma.timeLayout.findFirst({where:{classId:input.classId,name},include:{points:{orderBy:{position:'asc'}}}});
|
const current = await prisma.timeLayout.findFirst({
|
||||||
const previous = current ? toDto(current).items : [];
|
where: { classId: input.classId, name },
|
||||||
const starts=new Set(parsed.items.map(p=>p.startTime));
|
include: { points: { orderBy: { position: 'asc' } } },
|
||||||
const merged=input.mode==='merge'?parsed.items.filter(p=>previous.some(old=>old.startTime===p.startTime)).length:0;
|
|
||||||
const combined=input.mode==='merge'?[...previous.filter(p=>!starts.has(p.startTime)),...parsed.items].sort((a,b)=>a.startTime.localeCompare(b.startTime)):parsed.items;
|
|
||||||
const claimed=new Set<string>();
|
|
||||||
const points=combined.map((p,position)=>{
|
|
||||||
const timeType=({class:'LESSON',break:'BREAK',divider:'DIVIDER',action:'ACTION'} as const)[p.type];
|
|
||||||
const candidates=current?.points.filter(old=>!claimed.has(old.id)&&old.startTime===p.startTime&&old.timeType===timeType)||[];
|
|
||||||
const previousPoint=candidates.find(old=>old.endTime===p.endTime)||candidates[0];
|
|
||||||
if(previousPoint)claimed.add(previousPoint.id);
|
|
||||||
return {id:previousPoint?.id,position,name:p.name,startTime:p.startTime,endTime:p.endTime,timeType,isHideDefault:p.skipped,defaultClassId:null,breakName:p.type==='break'?p.name:'',actionSet:'{}'};
|
|
||||||
});
|
});
|
||||||
const layout=await saveLayout({classId:input.classId,name,points},current?.id);
|
const previous = current ? toDto(current).items : [];
|
||||||
|
const starts = new Set(parsed.items.map((p) => p.startTime));
|
||||||
|
const merged =
|
||||||
|
input.mode === 'merge'
|
||||||
|
? parsed.items.filter((p) => previous.some((old) => old.startTime === p.startTime)).length
|
||||||
|
: 0;
|
||||||
|
const combined =
|
||||||
|
input.mode === 'merge'
|
||||||
|
? [...previous.filter((p) => !starts.has(p.startTime)), ...parsed.items].sort((a, b) =>
|
||||||
|
a.startTime.localeCompare(b.startTime),
|
||||||
|
)
|
||||||
|
: parsed.items;
|
||||||
|
const claimed = new Set<string>();
|
||||||
|
const points = combined.map((p, position) => {
|
||||||
|
const timeType = ({ class: 'LESSON', break: 'BREAK', divider: 'DIVIDER', action: 'ACTION' } as const)[
|
||||||
|
p.type
|
||||||
|
];
|
||||||
|
const candidates =
|
||||||
|
current?.points.filter(
|
||||||
|
(old) => !claimed.has(old.id) && old.startTime === p.startTime && old.timeType === timeType,
|
||||||
|
) || [];
|
||||||
|
const previousPoint = candidates.find((old) => old.endTime === p.endTime) || candidates[0];
|
||||||
|
if (previousPoint) claimed.add(previousPoint.id);
|
||||||
|
return {
|
||||||
|
id: previousPoint?.id,
|
||||||
|
position,
|
||||||
|
name: p.name,
|
||||||
|
startTime: p.startTime,
|
||||||
|
endTime: p.endTime,
|
||||||
|
timeType,
|
||||||
|
isHideDefault: p.skipped,
|
||||||
|
defaultClassId: null,
|
||||||
|
breakName: p.type === 'break' ? p.name : '',
|
||||||
|
actionSet: '{}',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const layout = await saveLayout({ classId: input.classId, name, points }, current?.id);
|
||||||
logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`);
|
logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`);
|
||||||
return {layout:toDto(layout),mode:input.mode,warnings:parsed.warnings,replaced:parsed.items.length-merged,merged};
|
return {
|
||||||
|
layout: toDto(layout),
|
||||||
|
mode: input.mode,
|
||||||
|
warnings: parsed.warnings,
|
||||||
|
replaced: parsed.items.length - merged,
|
||||||
|
merged,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Promise<void> {
|
export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Promise<void> {
|
||||||
@@ -363,6 +396,7 @@ export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Pr
|
|||||||
if (!record) throw ApiError.notFound('时间配置不存在');
|
if (!record) throw ApiError.notFound('时间配置不存在');
|
||||||
if (!record.classId) throw ApiError.forbidden('全校时间表请在时间表编辑器管理');
|
if (!record.classId) throw ApiError.forbidden('全校时间表请在时间表编辑器管理');
|
||||||
await assertCanManageSchedule(user, record.classId);
|
await assertCanManageSchedule(user, record.classId);
|
||||||
if(await prisma.classPlan.count({where:{timeLayoutId:layoutId}}))throw ApiError.conflict('仍有课表使用该时间表');
|
if (await prisma.classPlan.count({ where: { timeLayoutId: layoutId } }))
|
||||||
|
throw ApiError.conflict('仍有课表使用该时间表');
|
||||||
await prisma.timeLayout.delete({ where: { id: layoutId } });
|
await prisma.timeLayout.delete({ where: { id: layoutId } });
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -61,11 +62,16 @@ export async function runPromotion(year: number, dryRun = false) {
|
|||||||
env.sqliteFilePath ? path.dirname(env.sqliteFilePath) : path.resolve(env.serverRoot, '..', 'data'),
|
env.sqliteFilePath ? path.dirname(env.sqliteFilePath) : path.resolve(env.serverRoot, '..', 'data'),
|
||||||
'promotion-snapshots',
|
'promotion-snapshots',
|
||||||
);
|
);
|
||||||
await fs.mkdir(dir, { recursive: true });
|
const snapshotPath = path.join(dir, `${year}-${Date.now()}-${randomUUID()}.json`);
|
||||||
const snapshotPath = path.join(dir, `${year}-${Date.now()}.json`);
|
try {
|
||||||
await fs.writeFile(snapshotPath, JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)), {
|
await fs.mkdir(dir, { recursive: true });
|
||||||
flag: 'wx',
|
await fs.writeFile(snapshotPath, JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)), {
|
||||||
});
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount);
|
||||||
|
throw ApiError.internal(`执行前快照失败,未升级任何班级:${String(error)}`);
|
||||||
|
}
|
||||||
const { config } = await promotionStatus();
|
const { config } = await promotionStatus();
|
||||||
const passwordHash = await hashPassword(env.defaultClassPassword);
|
const passwordHash = await hashPassword(env.defaultClassPassword);
|
||||||
const maxWeeks = await getMaxTermWeeks();
|
const maxWeeks = await getMaxTermWeeks();
|
||||||
@@ -149,17 +155,37 @@ export async function runPromotion(year: number, dryRun = false) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((await prisma.promotionRun.findUnique({ where: { year } }))?.status === 'done')
|
if ((await prisma.promotionRun.findUnique({ where: { year } }))?.status === 'done')
|
||||||
return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
|
return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
|
||||||
await prisma.promotionRun.upsert({
|
await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount);
|
||||||
where: { year },
|
|
||||||
create: { year, status: 'failed', snapshotPath, message: String(error) },
|
|
||||||
update: { status: 'failed', message: String(error), snapshotPath },
|
|
||||||
});
|
|
||||||
throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`);
|
throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`);
|
||||||
}
|
}
|
||||||
await notifyScheduleChanged();
|
await notifyScheduleChanged();
|
||||||
return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
|
return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 失败也留可见审计;事务内检查,不能把并发已成功的年份覆盖成失败。 */
|
||||||
|
async function recordPromotionFailure(
|
||||||
|
year: number,
|
||||||
|
snapshotPath: string,
|
||||||
|
error: unknown,
|
||||||
|
classCount: number,
|
||||||
|
studentCount: number,
|
||||||
|
) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.promotionRun.findUnique({ where: { year } });
|
||||||
|
if (current?.status === 'done') return;
|
||||||
|
const data = {
|
||||||
|
status: 'failed',
|
||||||
|
snapshotPath,
|
||||||
|
message: String(error),
|
||||||
|
classCount,
|
||||||
|
studentCount,
|
||||||
|
finishedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (current) await tx.promotionRun.update({ where: { year }, data });
|
||||||
|
else await tx.promotionRun.create({ data: { year, ...data } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
export async function startPromotionScheduler() {
|
export async function startPromotionScheduler() {
|
||||||
if (timer) return;
|
if (timer) return;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { PermissionScope as ContractPermissionScope, ReleaseProgressDto as ContractReleaseProgressDto } from './class-plan.js';
|
import type {
|
||||||
|
PermissionScope as ContractPermissionScope,
|
||||||
|
ReleaseProgressDto as ContractReleaseProgressDto,
|
||||||
|
} from './class-plan.js';
|
||||||
/**
|
/**
|
||||||
* 三端共享的领域类型定义。
|
* 三端共享的领域类型定义。
|
||||||
* 这里的类型是后端 REST API / WebSocket 契约的唯一来源,
|
* 这里的类型是后端 REST API / WebSocket 契约的唯一来源,
|
||||||
@@ -885,6 +888,8 @@ export interface BulkCreateHomeworkRequest {
|
|||||||
|
|
||||||
/* ------------------------------------------------------------------ 成绩 */
|
/* ------------------------------------------------------------------ 成绩 */
|
||||||
|
|
||||||
|
export type GradeUpdateEvent = GradeDto | { classId: string; collective: true };
|
||||||
|
|
||||||
export interface GradeDto {
|
export interface GradeDto {
|
||||||
id: string;
|
id: string;
|
||||||
classId: string;
|
classId: string;
|
||||||
@@ -1194,7 +1199,7 @@ export interface ServerToClientEvents {
|
|||||||
/** deleted=true 表示该作业已被删除,客户端应从本地缓存中移除 */
|
/** deleted=true 表示该作业已被删除,客户端应从本地缓存中移除 */
|
||||||
'homework:updated': (payload: HomeworkDto & { deleted?: boolean }) => void;
|
'homework:updated': (payload: HomeworkDto & { deleted?: boolean }) => void;
|
||||||
'homework:status': (payload: HomeworkStatusDto & { classId: string }) => void;
|
'homework:status': (payload: HomeworkStatusDto & { classId: string }) => void;
|
||||||
'grade:updated': (payload: GradeDto) => void;
|
'grade:updated': (payload: GradeUpdateEvent) => void;
|
||||||
'schedule:updated': (payload: {
|
'schedule:updated': (payload: {
|
||||||
classId: string;
|
classId: string;
|
||||||
/** imported = 导入或整体刷新课表 */
|
/** imported = 导入或整体刷新课表 */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { io, type Socket } from 'socket.io-client';
|
|||||||
import {
|
import {
|
||||||
PRIORITY_LABELS,
|
PRIORITY_LABELS,
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
type GradeDto,
|
type GradeUpdateEvent,
|
||||||
type HomeworkDto,
|
type HomeworkDto,
|
||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
@@ -96,9 +96,15 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
|||||||
dispatch(SOCKET_EVENTS.homeworkStatus, payload);
|
dispatch(SOCKET_EVENTS.homeworkStatus, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => {
|
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeUpdateEvent) => {
|
||||||
markEvent(SOCKET_EVENTS.gradeUpdated);
|
markEvent(SOCKET_EVENTS.gradeUpdated);
|
||||||
notify('success', '成绩已更新', `${payload.examName}:${payload.score}/${payload.totalScore}`);
|
notify(
|
||||||
|
'success',
|
||||||
|
'成绩已更新',
|
||||||
|
'score' in payload
|
||||||
|
? `${payload.examName}:${payload.score}/${payload.totalScore}`
|
||||||
|
: '班级成绩分析已更新',
|
||||||
|
);
|
||||||
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
|
dispatch(SOCKET_EVENTS.gradeUpdated, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user