修改课表模块

This commit is contained in:
yoyuzh
2026-03-14 22:55:07 +08:00
parent 6cff15f8dc
commit 033ac5bee4
22 changed files with 2730 additions and 115 deletions

View File

@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { CourseResponse } from './types';
import { buildScheduleTable, getScheduleCellHeight, getScheduleDividerOffsets } from './schedule-table';
test('buildScheduleTable creates 12 sections with empty slots preserved', () => {
const schedule: CourseResponse[] = [
{
courseName: 'Advanced Java',
teacher: 'Li',
classroom: 'A101',
dayOfWeek: 1,
startTime: 1,
endTime: 2,
},
{
courseName: 'Networks',
teacher: 'Wang',
classroom: 'B202',
dayOfWeek: 3,
startTime: 5,
endTime: 6,
},
];
const table = buildScheduleTable(schedule);
assert.equal(table.length, 12);
assert.equal(table[0].slots.length, 7);
assert.equal(table[0].section, 1);
assert.equal(table[11].section, 12);
assert.equal(table[0].period, 'morning');
assert.equal(table[4].period, 'noon');
assert.equal(table[5].period, 'afternoon');
assert.equal(table[9].period, 'evening');
assert.equal(table[0].slots[0]?.course?.courseName, 'Advanced Java');
assert.equal(table[1].slots[0]?.type, 'covered');
assert.equal(table[2].slots[0]?.type, 'empty');
assert.equal(table[4].slots[2]?.course?.courseName, 'Networks');
assert.equal(table[5].slots[2]?.type, 'covered');
assert.equal(table[8].slots[4]?.type, 'empty');
assert.equal(table[8].slots[6]?.type, 'empty');
});
test('buildScheduleTable clamps invalid section ranges safely', () => {
const schedule: CourseResponse[] = [
{
courseName: 'Night Studio',
teacher: 'Xu',
classroom: 'C303',
dayOfWeek: 5,
startTime: 11,
endTime: 14,
},
];
const table = buildScheduleTable(schedule);
assert.equal(table[10].slots[4]?.rowSpan, 2);
assert.equal(table[11].slots[4]?.type, 'covered');
});
test('getScheduleCellHeight returns merged visual height for rowspan cells', () => {
assert.equal(getScheduleCellHeight(1), 96);
assert.equal(getScheduleCellHeight(2), 200);
assert.equal(getScheduleCellHeight(4), 408);
});
test('getScheduleDividerOffsets returns internal section boundaries for merged cells', () => {
assert.deepEqual(getScheduleDividerOffsets(1), []);
assert.deepEqual(getScheduleDividerOffsets(2), [100]);
assert.deepEqual(getScheduleDividerOffsets(4), [100, 204, 308]);
});

View File

@@ -0,0 +1,77 @@
import type { CourseResponse } from './types';
export interface ScheduleSlot {
type: 'empty' | 'course' | 'covered';
course?: CourseResponse;
rowSpan?: number;
}
export interface ScheduleRow {
section: number;
period: 'morning' | 'noon' | 'afternoon' | 'evening';
slots: ScheduleSlot[];
}
const SECTION_COUNT = 12;
const WEEKDAY_COUNT = 7;
const SECTION_CELL_HEIGHT = 96;
const SECTION_CELL_GAP = 8;
function getPeriod(section: number): ScheduleRow['period'] {
if (section <= 4) {
return 'morning';
}
if (section === 5) {
return 'noon';
}
if (section <= 8) {
return 'afternoon';
}
return 'evening';
}
export function buildScheduleTable(schedule: CourseResponse[]) {
const rows: ScheduleRow[] = Array.from({ length: SECTION_COUNT }, (_, index) => ({
section: index + 1,
period: getPeriod(index + 1),
slots: Array.from({ length: WEEKDAY_COUNT }, () => ({ type: 'empty' as const })),
}));
for (const course of schedule) {
const dayIndex = (course.dayOfWeek ?? 0) - 1;
if (dayIndex < 0 || dayIndex >= WEEKDAY_COUNT) {
continue;
}
const startSection = Math.max(1, Math.min(SECTION_COUNT, course.startTime ?? 1));
const endSection = Math.max(startSection, Math.min(SECTION_COUNT, course.endTime ?? startSection));
const rowSpan = endSection - startSection + 1;
const startRowIndex = startSection - 1;
rows[startRowIndex].slots[dayIndex] = {
type: 'course',
course,
rowSpan,
};
for (let section = startSection + 1; section <= endSection; section += 1) {
rows[section - 1].slots[dayIndex] = {
type: 'covered',
};
}
}
return rows;
}
export function getScheduleCellHeight(rowSpan: number) {
const safeRowSpan = Math.max(1, rowSpan);
return safeRowSpan * SECTION_CELL_HEIGHT + (safeRowSpan - 1) * SECTION_CELL_GAP;
}
export function getScheduleDividerOffsets(rowSpan: number) {
const safeRowSpan = Math.max(1, rowSpan);
return Array.from({ length: safeRowSpan - 1 }, (_, index) =>
(index + 1) * SECTION_CELL_HEIGHT + index * SECTION_CELL_GAP + SECTION_CELL_GAP / 2,
);
}

22
front/src/lib/school.ts Normal file
View File

@@ -0,0 +1,22 @@
import { apiRequest } from './api';
import { writeCachedValue } from './cache';
import { getSchoolResultsCacheKey, writeStoredSchoolQuery } from './page-cache';
import type { LatestSchoolDataResponse } from './types';
export async function fetchLatestSchoolData() {
return apiRequest<LatestSchoolDataResponse | null>('/cqu/latest');
}
export function cacheLatestSchoolData(latest: LatestSchoolDataResponse) {
writeStoredSchoolQuery({
studentId: latest.studentId,
semester: latest.semester,
});
writeCachedValue(getSchoolResultsCacheKey(latest.studentId, latest.semester), {
queried: true,
studentId: latest.studentId,
semester: latest.semester,
schedule: latest.schedule,
grades: latest.grades,
});
}

View File

@@ -46,3 +46,10 @@ export interface GradeResponse {
grade: number | null;
semester: string | null;
}
export interface LatestSchoolDataResponse {
studentId: string;
semester: string;
schedule: CourseResponse[];
grades: GradeResponse[];
}