feat(server): 统一课表解析并完善权限范围与年度升级
This commit is contained in:
1 parent
66ef878a71
commit
4dc824c283
54 files changed
+2401
-625
No files matched your search
@@ -1,7 +1,6 @@
|
||||
import { importProfile } from '../class-plans/profile-import.service.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { assertCanManageSchedule } from '../../lib/access.js';
|
||||
import { weekParityFromDiv } from '@classhelper/shared';
|
||||
import type { ClassPlanImportInput } from './imports.schemas.js';
|
||||
@@ -20,7 +19,7 @@ import type { ClassPlanImportInput } from './imports.schemas.js';
|
||||
* ```
|
||||
*
|
||||
* 关键语义(与 ClassIsland 源码一致):
|
||||
* - `TimeRule.WeekDay`:0=周日、1=周一 … 6=周六;
|
||||
* - `TimeRule.WeekDay`:0=不按天、1=周一 … 7=周日;
|
||||
* - `TimeRule.WeekCountDiv`:0=每周;1=单周;2=双周(`WeekCountDivTotal=2` 时);
|
||||
* 一般规则:`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based);
|
||||
* - `Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0`(上课)的点**,课间/分割线不占位;
|
||||
@@ -184,18 +183,9 @@ export function parseClassIslandClassPlan(input: unknown): ClassPlanParseResult
|
||||
const rule = asRecord(plan.TimeRule ?? plan.timeRule) ?? {};
|
||||
const rawWeekDay = Number(rule.WeekDay ?? rule.weekDay ?? 0);
|
||||
// ClassIsland:0=周日 … 6=周六 → 我们:1=周一 … 7=周日
|
||||
const dayOfWeek = Number.isFinite(rawWeekDay)
|
||||
? rawWeekDay === 0
|
||||
? 7
|
||||
: Math.min(7, Math.max(1, rawWeekDay))
|
||||
: 1;
|
||||
const dayOfWeek = Number.isFinite(rawWeekDay) ? Math.min(7, Math.max(0, rawWeekDay)) : 1;
|
||||
const weekCountDiv = Number(rule.WeekCountDiv ?? rule.weekCountDiv ?? 0) || 0;
|
||||
const weekCountDivTotal = Number(rule.WeekCountDivTotal ?? rule.weekCountDivTotal ?? 2) || 2;
|
||||
if (weekCountDivTotal > 2 && weekCountDiv > 0) {
|
||||
result.warnings.push(
|
||||
`${planName}:${weekCountDivTotal} 周轮换无法用"单双周"表达,已按每周处理(第 ${weekCountDiv} 周)`,
|
||||
);
|
||||
}
|
||||
const weekParity = resolveParity(weekCountDiv, weekCountDivTotal);
|
||||
|
||||
const layoutId = String(plan.TimeLayoutId ?? plan.timeLayoutId ?? '');
|
||||
@@ -275,109 +265,6 @@ export async function previewClassPlan(
|
||||
* - replace:先清空该班课表再写入(失败回滚:整个过程在一个事务里);
|
||||
* - merge:按 `星期 + 开始时间 + 单双周` 去重,存在则更新时间/课程,否则新增。
|
||||
*/
|
||||
export async function importClassPlan(
|
||||
user: TokenPayload,
|
||||
input: ClassPlanImportInput,
|
||||
): Promise<{
|
||||
imported: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
createdCourses: string[];
|
||||
weekStart: number;
|
||||
weekEnd: number;
|
||||
}> {
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
const parsed = parseClassIslandClassPlan(input.payload);
|
||||
if (parsed.entries.length === 0) {
|
||||
throw new ApiError(400, 'IMPORT_INVALID', parsed.errors[0] ?? '没有可导入的课程');
|
||||
}
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { id: true, teacherId: true, termWeeks: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
// 导入范围与本班配置的学期长度一致。
|
||||
const weekStart = input.termStartWeek ?? 1;
|
||||
const weekEnd = classRecord.termWeeks;
|
||||
if (weekStart > weekEnd) throw ApiError.badRequest('导入起始周不能超出本班学期周数');
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1) 补建缺失课程(用班主任作为课程任课老师)
|
||||
const existingCourses = await tx.course.findMany({
|
||||
where: { classId: input.classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const courseIdByName = new Map(existingCourses.map((item) => [item.name, item.id]));
|
||||
const createdCourses: string[] = [];
|
||||
for (const name of parsed.subjects) {
|
||||
if (courseIdByName.has(name)) continue;
|
||||
const created = await tx.course.create({
|
||||
data: { classId: input.classId, name, teacherId: classRecord.teacherId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
courseIdByName.set(created.name, created.id);
|
||||
createdCourses.push(created.name);
|
||||
}
|
||||
|
||||
// 2) replace 模式:先清空该班课表
|
||||
if (input.mode === 'replace') {
|
||||
await tx.schedule.deleteMany({ where: { classId: input.classId } });
|
||||
}
|
||||
|
||||
// 3) 写入课程(merge 时按 星期+开始时间+单双周 去重)
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const entry of parsed.entries) {
|
||||
const courseId = courseIdByName.get(entry.subject);
|
||||
if (!courseId) continue;
|
||||
const existing = await tx.schedule.findFirst({
|
||||
where: {
|
||||
classId: input.classId,
|
||||
dayOfWeek: entry.dayOfWeek,
|
||||
startTime: entry.startTime,
|
||||
weekParity: entry.weekParity,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await tx.schedule.update({
|
||||
where: { id: existing.id },
|
||||
data: { courseId, endTime: entry.endTime, weekStart, weekEnd },
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
await tx.schedule.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
courseId,
|
||||
dayOfWeek: entry.dayOfWeek,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
location: null,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
weekParity: entry.weekParity,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
return { created, updated, createdCourses };
|
||||
});
|
||||
|
||||
const total = result.created + result.updated;
|
||||
logger.info(
|
||||
`ClassIsland 课程表导入:班级=${input.classId} 模式=${input.mode} 新增=${result.created} 更新=${result.updated} ` +
|
||||
`补建科目=[${result.createdCourses.join(',')}]`,
|
||||
);
|
||||
return {
|
||||
imported: total,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
createdCourses: result.createdCourses,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
};
|
||||
export async function importClassPlan(user: TokenPayload, input: ClassPlanImportInput) {
|
||||
return importProfile(user, input.classId, input.payload, input.mode);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiError, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireAnyPermission } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -151,7 +151,7 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re
|
||||
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
|
||||
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
|
||||
*/
|
||||
router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (req, res) => {
|
||||
router.post('/time-layout', requirePermission('timetable:manage'), validate({ body: timeLayoutImportSchema }), async (req, res) => {
|
||||
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
|
||||
sendOk(
|
||||
res,
|
||||
@@ -163,7 +163,7 @@ router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (r
|
||||
});
|
||||
|
||||
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
|
||||
router.delete('/time-layout/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||
router.delete('/time-layout/:id', requirePermission('timetable:manage'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await deleteTimeLayout(getAuthUser(req), id);
|
||||
sendOk(res, { id }, '时间配置已删除');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
|
||||
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
@@ -329,7 +330,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
const students = await prisma.student.findMany({
|
||||
where: { classId },
|
||||
where: { classId, ...activeStudentWhere() },
|
||||
select: { id: true, studentNo: true, name: true },
|
||||
});
|
||||
// 学号在库里已归一化为大写,比对前统一一次
|
||||
@@ -565,9 +566,11 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
|
||||
const existing = await prisma.student.findUnique({
|
||||
where: { studentNo },
|
||||
select: { id: true, classId: true },
|
||||
select: { id: true, classId: true, status: true },
|
||||
});
|
||||
|
||||
if (existing && !isActiveStudent(existing))
|
||||
throw ApiError.conflict('该学号已被历史学生占用,不能通过导入复用');
|
||||
if (existing) {
|
||||
if (input.mode === 'append') {
|
||||
skipped += 1;
|
||||
@@ -737,7 +740,7 @@ export async function commitClassTeachers(
|
||||
where: { archivedYearId: null },
|
||||
select: { id: true, name: true, teacherId: true },
|
||||
});
|
||||
const classByName = new Map<string, { id: string; name: string; teacherId: string }>();
|
||||
const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
|
||||
for (const item of classes) {
|
||||
const key = item.name.toLowerCase();
|
||||
if (classByName.has(key)) classByName.delete(key);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { saveLayout } from '../class-plans/class-plans.service.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
@@ -232,7 +233,7 @@ export function parseClassIslandTimeLayout(input: unknown): TimeLayoutParseResul
|
||||
|
||||
export interface TimeLayoutDto {
|
||||
id: string;
|
||||
classId: string;
|
||||
classId: string | null;
|
||||
name: string;
|
||||
source: string;
|
||||
items: ParsedTimeLayoutItem[];
|
||||
@@ -242,10 +243,18 @@ export interface TimeLayoutDto {
|
||||
|
||||
function toDto(record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
classId: string | null;
|
||||
name: string;
|
||||
source: string;
|
||||
items: string;
|
||||
points?: Array<{
|
||||
position: number;
|
||||
name: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
timeType: string;
|
||||
isHideDefault: boolean;
|
||||
}>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): TimeLayoutDto {
|
||||
@@ -255,6 +264,21 @@ function toDto(record: {
|
||||
} catch {
|
||||
items = [];
|
||||
}
|
||||
if (record.points?.length)
|
||||
items = record.points.map((p) => ({
|
||||
index: p.position,
|
||||
name: p.name,
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
type: (p.timeType === 'LESSON'
|
||||
? 'class'
|
||||
: p.timeType === 'BREAK'
|
||||
? 'break'
|
||||
: p.timeType === 'DIVIDER'
|
||||
? 'divider'
|
||||
: 'action') as ParsedTimeLayoutItem['type'],
|
||||
skipped: p.isHideDefault,
|
||||
}));
|
||||
return {
|
||||
id: record.id,
|
||||
classId: record.classId,
|
||||
@@ -284,13 +308,17 @@ function safeJson(text: string): unknown {
|
||||
|
||||
export async function listTimeLayouts(user: TokenPayload, classId: string): Promise<TimeLayoutDto[]> {
|
||||
await assertCanManageSchedule(user, classId);
|
||||
const records = await prisma.timeLayout.findMany({ where: { classId }, orderBy: { updatedAt: 'desc' } });
|
||||
const records = await prisma.timeLayout.findMany({
|
||||
where: { classId },
|
||||
include: { points: { orderBy: { position: 'asc' } } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return records.map(toDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入(写入):解析 → 有错误直接 400(不写库)→ 按 replace/merge 落库。
|
||||
* 权限与"课表管理"一致:管理员或本班班主任。
|
||||
* 写入路由要求 timetable:manage,班级范围仍由本服务校验。
|
||||
*/
|
||||
export async function importTimeLayout(
|
||||
user: TokenPayload,
|
||||
@@ -312,76 +340,29 @@ export async function importTimeLayout(
|
||||
}
|
||||
|
||||
const name = input.name?.trim() || '默认时间表';
|
||||
const existing = await prisma.timeLayout.findMany({
|
||||
where: { classId: input.classId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
const current = await prisma.timeLayout.findFirst({where:{classId:input.classId,name},include:{points:{orderBy:{position:'asc'}}}});
|
||||
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 current = existing.find((item) => item.name === name) ?? existing[0] ?? null;
|
||||
|
||||
if (input.mode === 'merge' && current) {
|
||||
let previousItems: ParsedTimeLayoutItem[] = [];
|
||||
try {
|
||||
previousItems = JSON.parse(current.items) as ParsedTimeLayoutItem[];
|
||||
} catch {
|
||||
previousItems = [];
|
||||
}
|
||||
|
||||
// 以"开始时间"为槽位标识:
|
||||
// - 导入项与旧配置的开始时间相同 → 视为同一节次被覆盖(计入 merged)
|
||||
// - 开始时间不同 → 视为新增(计入 replaced),旧节次全部保留
|
||||
// 注意:旧配置里可能存在开始时间相同的多条(例如分割线/跳过的节次),
|
||||
// 只有被此次导入显式覆盖的那些才移除,避免合并时静默丢数据。
|
||||
const incomingStarts = new Set(parsed.items.map((item) => item.startTime));
|
||||
const previousStarts = new Set(previousItems.map((item) => item.startTime));
|
||||
const merged = parsed.items.filter((item) => previousStarts.has(item.startTime)).length;
|
||||
const replaced = parsed.items.length - merged;
|
||||
const preserved = previousItems.filter((item) => !incomingStarts.has(item.startTime));
|
||||
const combined = [...preserved, ...parsed.items].sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
|
||||
const updated = await prisma.timeLayout.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
items: JSON.stringify(combined.map((item, index) => ({ ...item, index: index + 1 }))),
|
||||
source: 'classisland',
|
||||
},
|
||||
});
|
||||
logger.info(
|
||||
`课表时间配置合并导入:班级=${input.classId} 覆盖=${merged} 新增=${replaced} 共 ${combined.length} 节`,
|
||||
);
|
||||
return { layout: toDto(updated), mode: 'merge', warnings: parsed.warnings, replaced, merged };
|
||||
}
|
||||
|
||||
// replace:同一班级同名配置整体替换;没有则新建
|
||||
if (current) {
|
||||
const updated = await prisma.timeLayout.update({
|
||||
where: { id: current.id },
|
||||
data: { items: JSON.stringify(parsed.items), name, source: 'classisland' },
|
||||
});
|
||||
logger.info(`课表时间配置覆盖导入:班级=${input.classId} 共 ${parsed.items.length} 节`);
|
||||
return {
|
||||
layout: toDto(updated),
|
||||
mode: 'replace',
|
||||
warnings: parsed.warnings,
|
||||
replaced: parsed.items.length,
|
||||
merged: 0,
|
||||
};
|
||||
}
|
||||
const created = await prisma.timeLayout.create({
|
||||
data: { classId: input.classId, name, source: 'classisland', items: JSON.stringify(parsed.items) },
|
||||
});
|
||||
logger.info(`课表时间配置新建:班级=${input.classId} 共 ${parsed.items.length} 节`);
|
||||
return {
|
||||
layout: toDto(created),
|
||||
mode: 'replace',
|
||||
warnings: parsed.warnings,
|
||||
replaced: parsed.items.length,
|
||||
merged: 0,
|
||||
};
|
||||
const layout=await saveLayout({classId:input.classId,name,points},current?.id);
|
||||
logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`);
|
||||
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> {
|
||||
const record = await prisma.timeLayout.findUnique({ where: { id: layoutId } });
|
||||
if (!record) throw ApiError.notFound('时间配置不存在');
|
||||
if (!record.classId) throw ApiError.forbidden('全校时间表请在时间表编辑器管理');
|
||||
await assertCanManageSchedule(user, record.classId);
|
||||
if(await prisma.classPlan.count({where:{timeLayoutId:layoutId}}))throw ApiError.conflict('仍有课表使用该时间表');
|
||||
await prisma.timeLayout.delete({ where: { id: layoutId } });
|
||||
}
|
||||
Reference in new issue
Block a user