前言
大家好!今天我想和大家聊聊如何在自己的 Astro 博客中添加一个功能完整的课程表页面。这个功能其实挺实用的,特别是对于学生党来说,可以随时查看自己的课程安排。
整个实现涉及的知识点还挺多的,包括:
- 数据结构设计 - 如何设计一个合理的数据格式来存储课程信息
- 数据解析 - 如何把 JSON 数据转换成可用的数据结构
- 类型系统 - TypeScript 类型的设计和使用
- 组件架构 - 如何拆分和组织各个组件
- 响应式布局 - 桌面端和移动端的适配
- 实时状态 - 如何显示当前的上课状态
整体架构
在深入代码之前,我们先来看看整个课表页面的架构:
- 数据层
- JSON 文件存储课程数据,包含课程定义、时间安排、教室信息等
- 解析层
- TypeScript 工具函数负责解析 JSON 并计算当前周次
- 类型层
- 定义完整的数据类型系统,确保类型安全
- 组件层
- Astro 组件负责渲染页面结构和样式
- 交互层
- 客户端脚本实现实时状态更新和交互功能
这个分层设计的好处是职责清晰,每一层只关心自己的事情,方便维护和扩展。
数据结构设计
JSON 数据格式
首先我们来看看课程数据是怎么存储的。在 fuwari 中,课程数据存储在一个 JSON 文件里,格式是这样的:
点击查看完整的 JSON 数据结构
{
"config": {
"courseLen": 2,
"id": 1,
"name": "默认配置"
},
"nodeTimes": [
{ "node": 1, "startTime": "08:00", "endTime": "09:40", "timeTable": 1 },
{ "node": 2, "startTime": "10:00", "endTime": "11:40", "timeTable": 1 }
],
"meta": {
"id": 1,
"tableName": "大三下",
"maxWeek": 20,
"nodes": 10,
"startDate": "2026-3-2",
"timeTable": 1,
"showSat": false,
"showSun": false
},
"courseDefinitions": [
{
"id": 1,
"courseName": "计算机网络",
"color": "#FF6B6B"
}
],
"arrangements": [
{
"id": 1,
"day": 1,
"startNode": 1,
"step": 2,
"startWeek": 1,
"endWeek": 16,
"teacher": "张老师",
"room": "A101"
}
]
}这个 JSON 结构采用了配置与数据分离的设计:
config- 全局配置,比如每节课的时长nodeTimes- 每节课的时间安排meta- 课表元数据,包括学期名称、最大周数、开学日期等courseDefinitions- 课程定义,存储课程名称和颜色arrangements- 课程安排,存储具体的上课时间和地点
TypeScript 类型定义
有了数据结构,接下来我们要定义对应的 TypeScript 类型。类型定义在 src/types/timetable.ts 文件中:
// 配置段
export interface TimetableConfigSegment {
courseLen: number; // 每节课的时长(节数)
id: number;
name: string;
}
// 节次时间
export interface TimetableNodeTime {
node: number; // 第几节
startTime: string; // 开始时间,如 "08:00"
endTime: string; // 结束时间,如 "09:40"
timeTable: number;
}
// 元数据
export interface TimetableMetaSegment {
id: number;
tableName: string; // 课表名称
maxWeek: number; // 最大周数
nodes: number; // 每天节数
startDate: string; // 开学日期
timeTable: number;
showSat?: boolean; // 是否显示周六
showSun?: boolean; // 是否显示周日
}数据解析层
JSON 解析器
接下来我们看看如何把 JSON 文件解析成 TypeScript 对象。解析逻辑在 src/utils/timetable-parser.ts 中:
点击查看 parser 完整代码
const EXPECTED_SEGMENT_COUNT = 5;
function parseJsonLine<T>(line: string, lineNumber: number): T {
const normalizedLine = line.endsWith(",") ? line.slice(0, -1) : line;
try {
return JSON.parse(normalizedLine) as T;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`课表数据解析失败:第 ${lineNumber} 行不是合法 JSON(${message})`,
);
}
}
export function parseTimetableText(rawText: string): ParsedTimetableData {
const lines = rawText
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length !== EXPECTED_SEGMENT_COUNT) {
throw new Error(
`课表数据结构错误:必须恰好包含 ${EXPECTED_SEGMENT_COUNT} 段 JSON,当前为 ${lines.length} 段`,
);
}
const segments = lines.map((line, index) =>
parseJsonLine<unknown>(line, index + 1),
);
const [config, nodeTimes, meta, courseDefinitions, arrangements] = segments;
// 类型断言...
return {
config: config as TimetableConfigSegment,
nodeTimes: nodeTimes as TimetableNodeTime[],
meta: meta as TimetableMetaSegment,
courseDefinitions: courseDefinitions as TimetableCourseDefinition[],
arrangements: arrangements as TimetableCourseArrangement[],
};
}- 按行分割文本,过滤空行
- 检查是否恰好有 5 段数据
- 逐行解析 JSON,处理末尾逗号
- 按顺序解构出各个部分
数据转换器
解析完 JSON 后,我们需要把原始数据转换成视图模型。这个工作在 src/utils/timetable-normalizer.ts 中完成:
当前周计算
首先是最关键的当前周计算功能:
function parseDateFromYmd(ymd: string): Date | null {
const parts = ymd.split("-").map((part) => Number(part));
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
return null;
}
const [year, month, day] = parts;
return new Date(year, month - 1, day);
}
export function resolveCurrentWeek(
startDateText: string,
maxWeek: number,
now: Date = new Date(),
): number {
const startDate = parseDateFromYmd(startDateText);
if (!startDate) {
return 1;
}
const msPerDay = 24 * 60 * 60 * 1000;
const diffDays = Math.floor((now.getTime() - startDate.getTime()) / msPerDay);
const week = Math.floor(diffDays / 7) + 1;
if (week < 1) return 1;
if (week > maxWeek) return 1;
return week;
}视图模型构建
然后是构建视图模型的核心函数:
export function buildTimetableViewModel(
data: ParsedTimetableData,
selectedWeek: number,
): TimetableViewModel {
const maxWeek = Math.max(1, data.meta.maxWeek || 1);
const week = Math.min(Math.max(1, selectedWeek), maxWeek);
// 转换节次时间
const nodeRows = toNodeRows(data);
// 转换星期列
const dayColumns = toDayColumns(data);
// 构建课程查找表
const courseMap = new Map(
data.courseDefinitions.map((course) => [course.id, course]),
);
// 按星期分组课程
const coursesByDay: Record<number, TimetableCourseView[]> = {};
for (const column of dayColumns) {
coursesByDay[column.day] = [];
}
// 筛选当前周的课程
for (const arrangement of data.arrangements) {
// 检查星期是否有效
if (arrangement.day < 1 || arrangement.day > 7) continue;
// 检查当前周是否在课程范围内
if (week < arrangement.startWeek || week > arrangement.endWeek) continue;
// 检查该星期是否在显示范围内
if (!(arrangement.day in coursesByDay)) continue;
const courseDef = courseMap.get(arrangement.id);
const courseName = courseDef?.courseName ?? `课程 #${arrangement.id}`;
const color = buildCourseColor(courseName, arrangement.id);
coursesByDay[arrangement.day].push(
toCourseView(arrangement, courseName, color, nodeRows),
);
}
// 对每天的课程按节次排序
for (const day of Object.keys(coursesByDay)) {
coursesByDay[Number(day)].sort(
(a, b) => a.startNode - b.startNode,
);
}
return {
tableName: data.meta.tableName || "课表",
maxWeek,
currentWeek: week,
weeks: Array.from({ length: maxWeek }, (_, index) => index + 1),
dayColumns,
nodeRows,
coursesByDay,
};
}- 周数过滤 - 只保留在当前周范围内的课程安排
- 颜色生成 - 根据课程名称和ID生成唯一的HSL颜色
- 时间计算 - 根据节次查找对应的开始和结束时间
- 排序 - 按开始节次排序,确保显示顺序正确
页面路由设计
首页路由
课表页面有两个路由:首页和具体周次页。首页路由在 src/pages/timetable.astro:
---
import fs from "node:fs";
import path from "node:path";
import type { TimetableViewModel } from "@/types/timetable";
import TimetablePageContent from "@components/timetable/TimetablePageContent.astro";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import {
buildTimetableViewModel,
resolveCurrentWeek,
} from "@utils/timetable-normalizer";
import { parseTimetableFile } from "@utils/timetable-parser-server";
const filePath = "src/data/timetable/大三下.json";
const absoluteFilePath = path.join(process.cwd(), filePath);
let viewModel: TimetableViewModel | null = null;
let loadError = "";
let isCurrentWeek = false;
let baselineText = "";
try {
baselineText = fs.readFileSync(absoluteFilePath, "utf-8");
const parsedData = parseTimetableFile(filePath);
const currentWeek = resolveCurrentWeek(
parsedData.meta.startDate,
parsedData.meta.maxWeek,
);
viewModel = buildTimetableViewModel(parsedData, currentWeek);
isCurrentWeek = viewModel.currentWeek === currentWeek;
} catch (error) {
loadError = error instanceof Error ? error.message : "课表数据加载失败";
}
---
<MainGridLayout title={viewModel ? `课表 - 第${viewModel.currentWeek}周` : "课表"}>
{viewModel ? (
<TimetablePageContent
viewModel={viewModel}
isCurrentWeek={isCurrentWeek}
baselineText={baselineText}
/>
) : (
<div class="card-base p-6 md:p-8">
<div class="text-red-500">课表加载失败:{loadError}</div>
</div>
)}
</MainGridLayout>动态路由
具体周次页使用 Astro 的动态路由功能,在 src/pages/timetable/[week].astro:
---
// ... 导入语句与首页相同
const weekParam = Number(Astro.params.week);
try {
baselineText = fs.readFileSync(absoluteFilePath, "utf-8");
const parsedData = parseTimetableFile(filePath);
const currentWeek = resolveCurrentWeek(
parsedData.meta.startDate,
parsedData.meta.maxWeek,
);
// 使用 URL 参数中的周次,如果无效则使用当前周
const selectedWeek =
Number.isFinite(weekParam) && weekParam >= 1
? Math.floor(weekParam)
: currentWeek;
viewModel = buildTimetableViewModel(parsedData, selectedWeek);
isCurrentWeek = viewModel.currentWeek === currentWeek;
} catch (error) {
// ... 错误处理
}
// 生成所有周次的静态路径
export function getStaticPaths() {
const parsedData = parseTimetableFile("src/data/timetable/大三下.json");
const maxWeek = Math.max(1, parsedData.meta.maxWeek || 1);
return Array.from({ length: maxWeek }, (_, index) => ({
params: {
week: String(index + 1),
},
}));
}
---- getStaticPaths - 在构建时生成所有周次的静态页面
- Astro.params.week - 获取 URL 中的周次参数
- 参数验证 - 检查周次是否有效,无效则使用当前周
- isCurrentWeek - 标记是否是当前周,用于显示"当前周"标签
组件层实现
页面容器组件
TimetablePageContent.astro 是整个页面的容器组件:
---
import type { TimetableViewModel } from "@/types/timetable";
import LiveTimetableStatus from "@components/timetable/LiveTimetableStatus.astro";
import TimetableDayList from "@components/timetable/TimetableDayList.astro";
import TimetableGrid from "@components/timetable/TimetableGrid.astro";
import { Icon } from "astro-icon/components";
interface Props {
viewModel: TimetableViewModel;
isCurrentWeek?: boolean;
baselineText: string;
}
const { viewModel, isCurrentWeek = false, baselineText } = Astro.props;
const liveStatusPayload = {
coursesByDay: viewModel.coursesByDay,
};
---
<div class="card-base p-6 md:p-8">
<!-- 页面头部 -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{viewModel.tableName}</h1>
<span class="text-sm text-white/60">共 {viewModel.maxWeek} 周</span>
</div>
<!-- 周次导航 -->
<div class="flex items-center gap-3 mb-5">
<a
href={`/timetable/${Math.max(1, viewModel.currentWeek - 1)}/`}
class="btn-regular h-9 w-9 rounded-lg flex items-center justify-center"
class:list={[{ "opacity-50": viewModel.currentWeek <= 1 }]}
>
<Icon name="lucide:chevron-left" class="text-lg" />
</a>
<span class="min-w-[4.5rem] text-center font-medium">
第 {viewModel.currentWeek} 周
</span>
<a
href={`/timetable/${Math.min(viewModel.maxWeek, viewModel.currentWeek + 1)}/`}
class="btn-regular h-9 w-9 rounded-lg flex items-center justify-center"
class:list={[{ "opacity-50": viewModel.currentWeek >= viewModel.maxWeek }]}
>
<Icon name="lucide:chevron-right" class="text-lg" />
</a>
{isCurrentWeek && (
<span class="rounded-lg border border-emerald-400/30
bg-emerald-500/10 px-2.5 py-1 text-sm
font-medium text-emerald-200">
当前周
</span>
)}
</div>
<!-- 实时状态 -->
<LiveTimetableStatus payload={liveStatusPayload} class="mb-4" />
<!-- 桌面端网格 -->
<TimetableGrid viewModel={viewModel} />
<!-- 移动端列表 -->
<TimetableDayList viewModel={viewModel} />
</div>桌面端网格组件
TimetableGrid.astro 实现了桌面端的课表网格布局:
---
import type { TimetableCourseView } from "@/types/timetable";
import TimetableCourseCard from "@components/timetable/TimetableCourseCard.astro";
interface Props {
viewModel: TimetableViewModel;
}
const { viewModel } = Astro.props;
// 计算需要显示的星期
const dayIndexes = viewModel.dayColumns.map((day) => day.day);
const dayCount = viewModel.dayColumns.length;
// 构建课程查找表,key 格式为 "星期-节次"
const courseMapByDayAndNode = new Map<string, TimetableCourseView[]>();
for (const day of dayIndexes) {
for (const course of viewModel.coursesByDay[day] ?? []) {
// 将课程按节次分组(每2节一组)
const pairStartNode =
course.startNode % 2 === 1 ? course.startNode : course.startNode - 1;
const key = `${day}-${Math.max(1, pairStartNode)}`;
const list = courseMapByDayAndNode.get(key) ?? [];
list.push(course);
courseMapByDayAndNode.set(key, list);
}
}
---
<div class="hidden md:block card-base w-full overflow-hidden"
style={`--day-count: ${dayCount}`}>
<!-- 表头 -->
<div class="timetable-header-grid border-b border-white/10">
<div class="px-3 py-2 text-xs border-r border-white/10">节次</div>
{viewModel.dayColumns.map((day) => (
<div class="px-3 py-2 text-sm font-semibold border-r last:border-r-0
border-white/10">
{day.label}
</div>
))}
</div>
<!-- 课程行 -->
{viewModel.nodeRows
.filter((row) => row.node % 2 === 1) // 只显示奇数节次(成对显示)
.map((row) => (
<div class="timetable-row-grid border-b last:border-b-0 border-white/10">
<!-- 节次信息 -->
<div class="px-3 py-3 border-r border-white/10">
<p class="text-xs font-medium">
第 {row.node}-{Math.min(row.node + 1, viewModel.nodeRows.length)} 节
</p>
<p class="text-[11px] text-white/80 mt-1">
{row.startTime} - {下一节结束时间}
</p>
</div>
<!-- 每天的课程 -->
{viewModel.dayColumns.map((day) => {
const key = `${day.day}-${row.node}`;
const courses = courseMapByDayAndNode.get(key) ?? [];
return (
<div class="px-2 py-2 border-r last:border-r-0 border-white/10
align-top min-h-[88px]">
{courses.length > 0 ? (
<div class="space-y-2">
{courses.map((course) => (
<TimetableCourseCard course={course} compact={true} />
))}
</div>
) : (
<div class="h-full flex items-center justify-center
text-[11px] text-white/60">—</div>
)}
</div>
);
})}
</div>
))}
</div>
<style>
.timetable-header-grid,
.timetable-row-grid {
display: grid;
grid-template-columns: 120px repeat(var(--day-count), minmax(180px, 1fr));
}
</style>- CSS Grid - 使用 Grid 布局实现表格结构
- 动态列数 - 通过 CSS 变量 --day-count 控制列数
- 课程分组 - 将课程按节次分组,每2节课显示在一行
- 响应式 - 使用
hidden md在移动端隐藏
课程卡片组件
TimetableCourseCard.astro 是显示单个课程的组件:
---
import type { TimetableCourseView } from "@/types/timetable";
interface Props {
course: TimetableCourseView;
compact?: boolean; // 是否紧凑模式(用于网格)
showTime?: boolean; // 是否显示时间(用于列表)
}
const { course, compact = false, showTime = false } = Astro.props;
const borderColor = course.color || "#6b7280";
---
<article
class:list={["course-card rounded-lg", compact ? "p-2.5" : "p-3"]}
style={`--course-color: ${borderColor}`}
>
<div class="flex items-start justify-between gap-2">
<h3 class:list={["font-semibold text-white leading-tight",
compact ? "text-xs" : "text-sm"]}>
{course.courseName}
</h3>
<span class="text-[10px] text-white/80 whitespace-nowrap">
{course.startWeek}-{course.endWeek}周
</span>
</div>
{showTime && (
<p class:list={["text-white/80 mt-1", compact ? "text-[11px]" : "text-xs"]}>
时间:{course.timeText}
</p>
)}
<p class:list={["text-white/80", showTime ? "mt-0.5" : "mt-1",
compact ? "text-[11px]" : "text-xs"]}>
教室:{course.room}
</p>
<p class:list={["text-white/80 mt-0.5", compact ? "text-[11px]" : "text-xs"]}>
教师:{course.teacher}
</p>
</article>
<style>
.course-card {
border-left: 4px solid var(--course-color);
background: color-mix(in oklab, var(--card-bg) 82%, var(--course-color) 18%);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--course-color) 38%, transparent);
}
</style>实时状态组件
LiveTimetableStatus.astro 实现了实时显示当前上课状态的功能:
点击查看实时状态组件完整代码
---
import type { TimetableCourseView } from "@/types/timetable";
interface Props {
payload: {
coursesByDay: Record<number, TimetableCourseView[]>;
};
}
const { payload } = Astro.props;
const payloadText = encodeURIComponent(JSON.stringify(payload));
---
<div data-live-status-root data-live-payload={payloadText}>
<p data-live-status class="text-sm font-semibold">状态计算中...</p>
<p class="mt-1 text-sm">
<span>下一堂课:</span>
<span data-live-next-detail class="font-medium">--</span>
<span data-live-next-tail></span>
</p>
</div>
<script is:inline>
function parseTimeToMinute(text) {
const parts = String(text || "").split(":");
if (parts.length !== 2) return null;
return Number(parts[0]) * 60 + Number(parts[1]);
}
function resolveLiveState(payload) {
const now = new Date();
const currentMinute = now.getHours() * 60 + now.getMinutes();
const day = now.getDay() === 0 ? 7 : now.getDay();
// 周末
if (day >= 6) {
return { status: "周末", nextDetail: "--", nextTail: "" };
}
// 获取今日课程
const courses = (payload?.coursesByDay?.[day] || [])
.map((course) => {
const match = course.timeText.match(/(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})/);
if (!match) return null;
return {
...course,
startMinute: parseTimeToMinute(match[1]),
endMinute: parseTimeToMinute(match[2]),
};
})
.filter(Boolean)
.sort((a, b) => a.startMinute - b.startMinute);
if (courses.length === 0) {
return { status: "无课", nextDetail: "--", nextTail: "" };
}
// 判断当前状态
let status = "无课";
for (let i = 0; i < courses.length; i++) {
const current = courses[i];
if (currentMinute >= current.startMinute && currentMinute < current.endMinute) {
status = `上课(${current.courseName})`;
break;
}
const next = courses[i + 1];
if (next && currentMinute >= current.endMinute && currentMinute < next.startMinute) {
status = `课间(下一节:${next.courseName})`;
break;
}
}
// 查找下一节课
const nextCourse = courses.find((c) => c.startMinute > currentMinute);
if (!nextCourse) {
return { status, nextDetail: "--", nextTail: "" };
}
const remainMinutes = nextCourse.startMinute - currentMinute;
const hours = Math.floor(remainMinutes / 60);
const minutes = remainMinutes % 60;
const timeText = hours > 0 ? `${hours}小时${minutes}分钟` : `${minutes}分钟`;
return {
status,
nextDetail: `${nextCourse.courseName} - ${nextCourse.room || "未填写"}`,
nextTail: `(${timeText}后)`,
nextColor: nextCourse.color,
};
}
// 更新状态显示
function updateLiveStatus(root) {
const payloadRaw = decodeURIComponent(root.dataset.livePayload || "%7B%7D");
const payload = JSON.parse(payloadRaw);
const state = resolveLiveState(payload);
root.querySelector("[data-live-status]").textContent = state.status;
root.querySelector("[data-live-next-detail]").textContent = state.nextDetail;
root.querySelector("[data-live-next-tail]").textContent = state.nextTail;
root.querySelector("[data-live-next-detail]").style.color = state.nextColor || "";
}
// 初始化并定时更新
function setupLiveStatus() {
document.querySelectorAll("[data-live-status-root]").forEach(updateLiveStatus);
setInterval(() => {
document.querySelectorAll("[data-live-status-root]").forEach(updateLiveStatus);
}, 30 * 1000); // 每30秒更新一次
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupLiveStatus);
} else {
setupLiveStatus();
}
</script>样式与交互
响应式设计
课表页面使用了响应式设计,桌面端显示网格,移动端显示列表:
<!-- 桌面端网格 -->
<TimetableGrid viewModel={viewModel} />
<!-- 移动端列表 -->
<TimetableDayList viewModel={viewModel} />在组件内部使用 Tailwind 的响应式类控制显示:
<!-- TimetableGrid.astro -->
<div class="hidden md:block">...</div>
<!-- TimetableDayList.astro -->
<div class="md:hidden">...</div>颜色生成
每个课程卡片都有一个唯一的颜色,通过哈希函数生成:
function hashText(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 31 + input.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
function buildCourseColor(courseName: string, courseId: number): string {
const seed = hashText(`${courseName}-${courseId}`);
const hue = seed % 360;
return `hsl(${hue} 78% 68%)`;
}可视化编辑器(进阶)
前面我们讲了如何展示课表,现在来讲讲如何让课表可编辑。TimetableVisualEditor.svelte 是一个功能完整的可视化编辑器,让用户可以直接在浏览器里增删改课程。
组件架构
这个编辑器采用双栏布局:左侧是课程列表,右侧是属性编辑面板。
- 编辑模式切换
- 提供"编辑课表"和"退出编辑"按钮,进入编辑模式后显示可视化界面
- 左侧课程列表
- 按星期分组显示所有课程卡片,点击选中进行编辑
- 右侧属性面板
- 根据选中课程或新增状态,显示对应的表单字段
- 数据验证
- 实时验证课程数据的合法性,防止错误数据
- 导出功能
- 将编辑后的数据导出为 JSON 文件
核心数据结构
编辑器使用了一些内部类型来管理编辑状态:
// 新增课程的草稿数据
type NewCourseDraft = {
courseName: string;
teacher: string;
room: string;
day: number;
startNode: number;
startWeek: number;
endWeek: number;
};
// 课程卡片显示项
type ArrangementCardItem = {
arrangementIndex: number;
title: string;
teacher: string;
room: string;
nodeText: string;
weekText: string;
color: string;
};
// 按星期分组的卡片
type ArrangementCardGroup = {
day: number;
label: string;
items: ArrangementCardItem[];
};状态管理
编辑器需要管理多个状态:
点击查看状态管理代码
let editMode = false; // 是否处于编辑模式
let draftParsed: ParsedTimetableData; // 编辑中的数据副本
let previewViewModel: TimetableViewModel; // 预览用的视图模型
let selectedArrangementRef: number | null; // 当前选中的课程索引
let validationError = ""; // 验证错误信息
let isDirty = false; // 数据是否有修改
let creatingCourse = false; // 是否正在创建新课程
let newCourseDraft: NewCourseDraft; // 新课程的草稿数据- 草稿模式 - 编辑时不直接修改原始数据,而是操作副本
- 响应式更新 - 使用 Svelte 的
$:语法自动计算派生状态 - 脏数据标记 -
isDirty标记是否有未保存的修改 - 验证反馈 - 实时显示验证错误,阻止非法操作
课程列表渲染
左侧课程列表按星期分组显示:
<div class="grid gap-3 md:grid-cols-2">
{#each arrangementCards as dayGroup}
<div class="rounded-lg border border-[var(--line-divider)]/80 p-3">
<div class="mb-2 text-sm font-medium">{dayGroup.label}</div>
{#if dayGroup.items.length === 0}
<p class="text-xs text-white/70">本日暂无课程</p>
{:else}
<div class="flex flex-col gap-2">
{#each dayGroup.items as item}
<button
class={selectedArrangementRef === item.arrangementIndex
? "选中样式"
: "默认样式"}
on:click={() => selectArrangement(item.arrangementIndex)}
>
<div style={`color:${item.color}`}>{item.title}</div>
<div>{item.nodeText} · {item.weekText}</div>
<div>{item.teacher} / {item.room}</div>
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>表单编辑与验证
属性面板支持两种模式:新增课程和编辑现有课程。
使用 HTML5 的datalist 元素,可以根据已有数据提供自动完成建议,比如已存在的课程名、教师名、教室名
表单验证确保数据的合法性:
function validateDraft(data: ParsedTimetableData): string {
for (let index = 0; index < data.arrangements.length; index += 1) {
const arrangement = data.arrangements[index];
// 验证星期范围
if (arrangement.day < 1 || arrangement.day > 7) {
return `第 ${index + 1} 条课程安排的星期超出范围(1-7)`;
}
// 验证节次范围
if (arrangement.startNode < 1 || arrangement.startNode > maxNode) {
return `第 ${index + 1} 条课程安排的起始节次超出范围`;
}
// 验证周次逻辑
if (arrangement.startWeek > arrangement.endWeek) {
return `第 ${index + 1} 条课程安排的起止周非法`;
}
// 验证课程名不能为空
const courseDef = data.courseDefinitions.find(c => c.id === arrangement.id);
if (!courseDef || !courseDef.courseName?.trim()) {
return `第 ${index + 1} 条课程安排关联课程名为空`;
}
}
return "";
}数据导出
编辑完成后,可以将数据导出为 JSON 文件:
function exportJson() {
const error = validateDraft(draftParsed);
if (error) {
validationError = error;
return;
}
const text = serializeTimetableDataToFileText(draftParsed);
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
// 创建临时链接并触发下载
const link = document.createElement("a");
link.href = url;
link.download = `${baselineParsed.meta.tableName || "timetable"}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}这个编辑器是临时编辑模式,导出后会自动退出编辑状态并恢复原始数据。这是因为:
- Astro 是静态站点生成器,无法直接修改服务器上的文件
- 用户需要手动将导出的 JSON 文件替换到数据目录
- 这种设计保证了数据安全,避免误操作
使用方式
在 TimetablePageContent 组件中引入编辑器:
---
import TimetableVisualEditor from "@components/timetable/TimetableVisualEditor.svelte";
---
<!-- 在适当位置插入编辑器 -->
<TimetableVisualEditor
viewModel={viewModel}
baselineText={baselineText}
client:load
/>总结
通过这篇文章,我们详细解析了一个完整的课程表页面的实现过程:
- 数据层
- 设计了清晰的 JSON 数据结构,分离配置、定义和安排
- 类型层
- 使用 TypeScript 定义完整类型,确保类型安全
- 解析层
- 实现 JSON 解析、数据转换和当前周计算
- 路由层
- 使用 Astro 动态路由生成所有周次页面
- 组件层
- 拆分多个组件,实现网格布局、列表布局、课程卡片
- 交互层
- 客户端脚本实现实时状态更新
可扩展的功能
如果你想进一步完善这个课表页面,可以考虑:
- 多课表支持 - 支持切换不同学期的课表
- 课程提醒 - 上课前发送浏览器通知
- 导出功能 - 导出为 ICS 日历文件
- 编辑功能 - 可视化编辑课程安排
- 数据同步 - 从教务系统自动同步课程
希望这篇教程对你有帮助!如果你有任何问题,欢迎在评论区留言。
Enter 感谢阅读!