一套零第三方依赖、开箱即用的学生课表管理系统。覆盖「录入 → 存储 → 导入导出 → 日历同步」完整链路,内置 REST API,支持通过 URL 订阅方式把课表实时同步到小米手机日历。
- 源码栈:Node.js(≥18)原生
node:http+ 单文件 JSON 持久化,不依赖任何 npm 包(含 Excel 与 ZIP 打包均为自实现) - 前端:原生 HTML/CSS/JS 单页管理端,无构建步骤,响应式适配手机
- 状态:78 项端到端冒烟测试全部通过(
npm test)
git clone https://github.com/akexin/student-timetable.git
cd student-timetable
node src/server.js # 或 npm start(无需 npm install)首次启动会生成 data/config.json(含 API Key)与示例学期数据(npm run seed 可重置示例)。控制台输出示例:
管理界面 : http://localhost:3000/
局域网地址 : http://172.16.0.76:3000/ ← 小米手机要访问的地址
API Key : sk_XXXXXXXXXXXXXXXXXXXXXXXX
日历订阅地址 : http://172.16.0.76:3000/ics/EViYjuC8iqlSCDMa.ics
浏览器打开管理界面后,点右上角「连接」填入 API Key 即可(也可直接用 http://<host>:3000/#key=<API_KEY> 自动填入)。
| 变量 | 默认 | 说明 |
|---|---|---|
PORT / --port |
3000 |
监听端口 |
HOST |
0.0.0.0 |
监听地址 |
TIMETABLE_API_KEY |
首次启动随机生成 | API 密钥 |
TIMETABLE_PUBLIC_BASE_URL |
按请求 Host 推断 | 强烈建议配置为局域网 IP 或域名,否则生成的订阅地址可能是 localhost,手机无法访问 |
TIMETABLE_REMIND_MINUTES |
15 |
ICS 事件提前提醒分钟数,0 表示不提醒 |
TIMETABLE_DATA_DIR |
./data |
数据目录 |
默认值即为 0.0.0.0,即监听本机全部网卡,局域网与(具备公网条件时的)公网均可访问:
// data/config.json
{ "host": "0.0.0.0", "port": 3000, "publicBaseUrl": "" }或临时指定:
HOST=0.0.0.0 PORT=3000 node src/server.js| 层次 | 要求 | 操作 |
|---|---|---|
| ① 监听 | host = 0.0.0.0 |
上表配置 |
| ② 主机防火墙 | 放行入站 TCP 端口 | 管理员 PowerShell:.\scripts\open-firewall.ps1 -Port 3000(关闭用 -Remove) |
| ③ 网络出口 | 拥有公网 IP 并做端口映射,或使用内网穿透 | 路由器「虚拟服务器/端口转发」;或用 frp / ngrok / 花生壳 等 |
注意:本机当前出口为私有地址(
172.16.x.x),说明处于 NAT 之后。仅绑定 0.0.0.0 并不能让外网直接访问,必须完成第 ③ 步。
订阅地址默认按请求 Host 头推断;若要固定(例如已配置域名或穿透地址),设置:
TIMETABLE_PUBLIC_BASE_URL=https://timetable.example.com node src/server.js此后 /api/subscription、/api/meta、管理端界面生成的订阅地址一律使用该基址。
| 项目 | 建议 |
|---|---|
| HTTPS | 公网务必用 Nginx/Caddy 反代并配置证书,否则 API Key 与订阅令牌明文传输 |
| API Key | 泄露后删除 data/config.json 中的 apiKey 字段并重启,服务会自动重新生成 |
| 订阅令牌 | 泄露后调用 POST /api/terms/:id/token 轮换,旧地址立即失效 |
| 管理页面 | 页面本身免鉴权但无 Key 无法读写任何数据;如需彻底隐藏,可在反代层对 / 加 Basic Auth,但需放行 /ics/ |
| 最小化暴露 | 若只在校园网使用,可用 HOST=172.16.0.76 绑定单网卡,而非 0.0.0.0 |
| 模块 | 说明 | 关键实现 |
|---|---|---|
| 学期管理 | 多学期并存;可配置学期名称、第一周周一(非周一自动对齐)、总周数(1–40)、自定义节次时间表 | POST/GET/PUT/DELETE /api/terms |
| 课程管理 | 课程增删改查、批量创建、批量删除、按星期/教师/关键字检索 | /api/courses |
| 周课表视图 | 按周渲染 7×N 节次矩阵,自动叠加节假日剔除与调休补课;点击空白格新增、点击卡片编辑 | /api/timetable |
| 节假日与调休 | holiday=当日课程取消(导出为 EXDATE);makeup=指定日期执行星期 X 的课表(导出为额外单次事件) |
/api/holidays |
| 导入中心 | 支持文件/文本/远程 URL;支持 JSON / CSV / ICS;三种冲突策略 | POST /api/import |
| 导出中心 | JSON / CSV / CSV 周视图 / Excel(xlsx) / ICS 五种输出 | GET /api/export |
| 日历订阅 | 每个学期一个只读令牌,生成可直接被手机日历订阅的地址,支持令牌轮换 | /ics/:token.ics、/api/subscription |
| 统计 | 课程数、总课次(已计入放假剔除)、学分、放假/调休天数、按星期分布 | /api/stats |
| Schema 自描述 | 对外暴露完整字段字典与 JSON Schema,便于第三方系统对接 | /api/schema |
管理端为单页应用,同一套页面同时服务桌面与手机,无需单独打包或安装 App。
双视图模式(右上角「周视图 / 日程列表」分段按钮随时切换):
| 视图 | 形态 | 适用 |
|---|---|---|
| 周视图 | 7 列 × N 节次矩阵,时间列冻结 | 桌面端整体浏览、打印张贴 |
| 日程列表 | 按天纵向排列的卡片列表,一天一段 | 手机端单手滑动查看 |
页面载入时按视口宽度自动选择:宽度 < 760px 默认进入「日程列表」,桌面默认「周视图」,切换后用户选择在本次会话内保持。
响应式要点(public/style.css):
| 断点 | 处理 |
|---|---|
≤ 900px |
左侧时间列 position: sticky 冻结,横向滑动查看整周;工具栏换行 |
≤ 640px |
按钮高度 ≥ 44px(触控友好)、字号与内边距收紧、表头吸顶 |
≤ 400px |
超小屏进一步压缩列宽与字号(如 iPhone SE) |
横屏 & 高度 ≤ 480px |
压缩纵向留白,保证一屏可见更多节次 |
触控与安全区:
- 按钮统一
touch-action: manipulation,消除移动端 300ms 点击延迟; - 横向滚动容器带
-webkit-overflow-scrolling: touch,滑动惯性更自然; - 底部浮动操作条与弹窗页脚使用
env(safe-area-inset-bottom),避开 iPhone 底部横条; - 点击周视图空白格 → 快速新增该时段课程;点击课程卡片 → 直接编辑。
手机访问方式:电脑与手机连同一 Wi-Fi,用局域网地址访问(启动时控制台「局域网地址」一行),例如 http://172.16.0.76:3000/#key=<API_KEY>,#key= 可自动填入密钥,省去手输。
统一由 src/model.js 的 FIELD_DEFS 定义,/api/schema 与本文档均由此生成,文档与实现永不脱节。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id |
string | ✅ | term_xxxxxxxx,服务端生成 |
name |
string | ✅ | 学期名称,如 2026-2027学年 秋季学期 |
startDate |
date | ✅ | 第一周周一(YYYY-MM-DD);传入非周一时自动对齐到当周周一 |
weeks |
integer | ✅ | 总周数,1–40 |
periods[] |
array | ✅ | 节次时间表,见下方 period |
subscriptionToken |
string | — | 只读,日历订阅令牌 |
createdAt / updatedAt |
datetime | — | ISO 8601 |
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
index |
integer | ✅ | 第几节,从 1 开始 |
name |
string | — | 如 第一节 |
startTime / endTime |
time | ✅ | HH:mm,结束必须晚于开始 |
默认内置 10 节(08:00–20:40),可在学期设置里任意增删改。
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
id |
string | — | 自动生成 | 课程唯一标识 |
termId |
string | ✅ | — | 所属学期 |
name |
string | ✅ | — | 课程名称 |
dayOfWeek |
integer 1–7 | ✅ | — | 1=周一 … 7=周日 |
startPeriod |
integer ≥1 | ✅ | — | 开始节次 |
endPeriod |
integer ≥1 | — | = startPeriod | 结束节次(连堂时 > startPeriod) |
teacher |
string | — | '' |
任课教师 |
location |
string | — | '' |
上课地点 |
weeks |
weeksRule | — | {mode:'all'} |
上课周次规则 |
startTime / endTime |
time | — | 由节次表推导 | 显式指定后优先于节次表 |
className |
string | — | '' |
班级/行政班 |
credit |
number | — | null |
学分 |
note |
string | — | '' |
备注 |
color |
string | — | '' |
界面色值,如 #4F7CF7 |
对象形式:
{ "mode": "all" } // 全学期
{ "mode": "range", "start": 1, "end": 16 } // 连续周
{ "mode": "odd", "start": 1, "end": 16 } // 单周
{ "mode": "even", "start": 2, "end": 16 } // 双周
{ "mode": "list", "list": [1, 3, 5] } // 离散周字符串简写(CSV 录入、API 均可使用,解析器见 parseWeeksRule):
| 写法 | 含义 |
|---|---|
1-16、1-16周 |
第 1–16 周 |
1-16单、单周 |
单周(起始缺省取 1,结束缺省取学期总周数) |
1-16双 |
双周 |
1,3,5-9 |
离散:1、3、5、6、7、8、9 |
all、*、全部、留空 |
全学期 |
[1,2,3](数组) |
离散周次 |
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
termId |
string | ✅ | 所属学期 |
date |
date | ✅ | YYYY-MM-DD |
type |
enum | ✅ | holiday 放假(当日课程取消)/makeup 调休补课 |
targetDayOfWeek |
integer 1–7 | 条件必填 | type=makeup 时必填:当日执行星期几的课表 |
name |
string | — | 说明 |
语义示例:
{date:'2026-10-10', type:'makeup', targetDayOfWeek:5}= 10 月 10 日(周六)按周五课表上课,若该周周五有课则会额外生成一天课程。
| 维度 | 支持情况 |
|---|---|
| 数据格式 | JSON、CSV(含 Excel/WPS 另存)、ICS/iCalendar |
| 数据来源 | ① 直接正文字符串 ② 本地文件(管理端上传 / multipart)③ 远程 URL(服务端主动拉取) |
| 冲突策略 | upsert(默认,按 id 或课程指纹去重更新)、merge(追加)、replace(清空该学期后导入) |
| 自动识别 | 不传 format 时按内容首字符自动判别:{/[→JSON;BEGIN:VCALENDAR→ICS;否则 CSV |
upsert 模式下,满足下列任一即视为同一门课(更新而非新增):
id相同;- 指纹相同 =
课程名 | 星期 | 开始节次 | 归一化开始时间 | 上课地点。
归一化说明:若一处用「第 1–2 节」表示、另一处用「08:00」表示,系统会按该学期的节次表换算成同一时间,因此不会误判为两门课。
POST /api/import
Content-Type: application/json
X-API-Key: <API_KEY>{
"format": "auto", // auto | json | csv | ics
"mode": "upsert", // upsert | merge | replace
"termId": "term_xxx", // 留空时:按数据内嵌学期信息匹配或自动新建
"data": "...", // 方式一:正文字符串;也可直接给 JSON 对象/数组
"url": "https://...", // 方式二:远程地址(与 data 二选一)
"contentType": "text/calendar", // 可选,辅助格式识别
"applyDefaults": { "className": "计科2301" } // 可选,统一补齐默认字段
}
multipart/form-data同样支持:字段file(文件)、termId、format、mode。
导入响应:
{
"ok": true,
"format": "csv",
"termId": "term_xxx",
"mode": "upsert",
"source": "CSV 共 18 行",
"total": 18,
"created": 16,
"updated": 2,
"skipped": 0,
"holidaysImported": 3,
"errors": [],
"details": [],
"courseIds": ["c_xxx", "..."]
}单项错误不会中断整批导入,会汇总到 errors(含行号/索引与原因),HTTP 状态码仍为 201;若存在行级错误则 ok=false,便于前端提示。
服务端会主动 GET 该地址并解码,因此可把任意第三方系统提供的 ICS/JSON 地址作为数据源,包括本系统另一个实例的导出接口:
{
"mode": "merge",
"url": "http://192.168.1.20:3000/api/export?format=ics&termId=term_xxx&api_key=sk_yyy"
}表头支持中文与英文别名,由 headerToKey() 做「忽略大小写 + 忽略空格」的归一化匹配:
| 表头 | key | 常用别名 |
|---|---|---|
| 课程名称 | name |
课程名 / courseName / title / subject |
| 教师 | teacher |
teacherName / instructor |
| 上课地点 | location |
地点 / place / room / classroom |
| 星期 | dayOfWeek |
day / weekday / dow(支持 1~7、周一…周日、Mon…Sun) |
| 开始节次 | startPeriod |
period |
| 结束节次 | endPeriod |
— |
| 开始时间 / 结束时间 | startTime / endTime |
start / end |
| 周次规则 | weeks |
周次 / weeksRule / weekRule |
| 班级 | className |
class / 班级名 |
| 学分 | credit |
credits / score |
| 备注 | note |
remark / comment / desc |
| 颜色 | color |
colour |
| 课程ID | id |
courseId |
模板下载:GET /api/export/template?format=csv(或 xlsx)。
已内置脚本 scripts/import-njpi.js,直接从教务系统拉取课表并写入本系统,无需手工抄录。
原理:教务「学生课表」页是 Vue 单页应用,数据来自
https://jwxt.njpi.edu.cn/eams-micro-server/api/v1/**,鉴权为请求头 X-Id-Token
(取页面 URL ?idToken= 之后、#/ 之前的部分)。
用到的接口:
| 接口 | 用途 |
|---|---|
/semester/current-semester |
学期 id、起止日期、周数 |
/semester/current-week/{id} |
当前第几周 |
/system/course-units?bizTypeId=&semesterId= |
节次时间表 |
/lesson/student/course-table/{id} |
课表本体(逐条 schedules,含具体日期) |
字段映射:
| 教务字段 | 本系统字段 |
|---|---|
course.nameZh / course.credits |
name / credit |
schedules[].weekday(1=周一…7=周日,已用 2026-09-16 周三核对) |
dayOfWeek |
schedules[].startUnit / endUnit |
startPeriod / endPeriod |
schedules[].startTime / endTime(如 1330) |
startTime / endTime |
同一「星期+节次+地点」归并后的 weekIndex 集合 |
weeks(自动归纳为 range/odd/even/list) |
customPlace 或 room.nameZh |
location |
teacherAssignmentList |
teacher |
用法:
# 试运行(只解析不写入)
node scripts/import-njpi.js --token <idToken> --dry-run --out njpi.json
# 正式导入(幂等:同名学期复用,课程整体 replace;--drop-demo 会删除示例学期)
node scripts/import-njpi.js --token <idToken> --drop-demo安全提示:
idToken是访问教务系统的凭据(内含学号与姓名,有效期约 30 天), 请勿提交到代码仓库或分享;失效后重新从教务课表页面复制 URL 即可。 脚本默认不持久化 token(通过命令行或环境变量NJPI_TOKEN传入)。
统一入口 GET /api/export?format=<格式>(需 X-API-Key 或 ?api_key=)。
| format | Content-Type | 说明 |
|---|---|---|
json(默认) |
application/json; charset=utf-8 |
完整结构化数据:学期 + 节次表 + 课程 + 节假日;加 &include=occurrences 额外展开每门课的全部上课日期 |
csv |
text/csv; charset=utf-8 |
扁平表,带 UTF-8 BOM,Excel/WPS 双击即开不乱码 |
csv-grid |
text/csv; charset=utf-8 |
周视图矩阵(行=节次,列=星期),便于打印张贴 |
xlsx |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
真 OOXML 工作簿,含 3 张工作表:课表 / 节假调休 / 学期与节次 |
ics |
text/calendar; charset=utf-8; component=vevent; method=PUBLISH |
iCalendar 日历,可直接导入或订阅 |
| 参数 | 适用格式 | 默认 | 说明 |
|---|---|---|---|
termId |
全部 | 当前最新学期 | 目标学期 |
download |
全部 | 1 |
1 带 Content-Disposition: attachment,0 则内联返回 |
pretty |
json | 1 |
0 时压缩 JSON |
include |
json | — | occurrences 时展开全部上课日期 |
mode |
ics | recurrence |
recurrence(RRULE+EXDATE)/flat(逐条事件) |
tz |
ics | floating |
floating(浮动本地时间)/tzid(输出 VTIMEZONE+TZID=) |
remind |
ics | 15 |
提前提醒分钟数,0=不生成 VALARM |
title |
ics | {name} |
事件标题模板,占位符 {name} {teacher} {location} {class} {note} |
{
"version": 1,
"exportedAt": "2026-10-09T02:50:00.000Z",
"generator": "student-timetable-system/1.0",
"term": {
"id": "term_8e1d70dccb9dfd16",
"name": "2026-2027学年 秋季学期",
"startDate": "2026-09-07",
"weeks": 20,
"periods": [{ "index": 1, "name": "第一节", "startTime": "08:00", "endTime": "08:45" }],
"subscriptionToken": "EViYjuC8iqlSCDMa"
},
"courses": [
{
"id": "c_feab6f640db3a02e",
"termId": "term_8e1d70dccb9dfd16",
"name": "高等数学A(上)",
"teacher": "张伟",
"location": "教三-301",
"dayOfWeek": 1,
"dayLabel": "周一",
"startPeriod": 1,
"endPeriod": 2,
"startTime": "08:00", // 由节次表推导
"endTime": "09:40",
"weeks": { "mode": "range", "start": 1, "end": 16 },
"weeksText": "1-16周",
"className": "计科2301",
"credit": 5,
"note": "",
"color": "#4F7CF7",
"createdAt": "2026-10-09T02:50:00.000Z",
"updatedAt": "2026-10-09T02:50:00.000Z"
}
],
"holidays": [
{ "id": "h_xxx", "termId": "term_xxx", "date": "2026-10-05", "type": "holiday", "name": "国庆节假期", "targetDayOfWeek": null }
]
}该结构可被
POST /api/import原样回灌(幂等),用于系统间迁移或备份还原。
除
GET /api/health与/ics/:token.ics外,所有/api/**接口均需鉴权: 请求头X-API-Key: <KEY>或Authorization: Bearer <KEY>或查询参数?api_key=<KEY>。 全接口已开放 CORS 并处理OPTIONS预检。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/health |
健康检查(免鉴权) |
| GET | /api/schema |
字段字典 + JSON Schema + 枚举值 |
| GET | /api/meta |
服务元信息与全部订阅地址 |
| GET / POST | /api/terms |
学期列表 / 新建 |
| GET / PUT / DELETE | /api/terms/:id |
学期详情 / 更新 / 删除(级联清除课程与节假日) |
| POST | /api/terms/:id/token |
轮换日历订阅令牌 |
| GET | /api/courses |
课程列表,支持 termId dayOfWeek teacher q 过滤 |
| POST | /api/courses |
新建课程,支持单条或数组批量 |
| GET / PUT / DELETE | /api/courses/:id |
课程详情 / 更新 / 删除 |
| POST | /api/courses/bulk-delete |
批量删除 {"ids":[...]} |
| GET / POST | /api/holidays |
节假日列表 / 新增(支持数组) |
| PUT / DELETE | /api/holidays/:id |
更新 / 删除 |
| GET | /api/timetable |
周视图,?termId=&week= |
| GET | /api/stats |
统计信息 |
| GET | /api/export |
导出(见第五节) |
| GET | /api/export/template |
下载导入模板 ?format=csv|xlsx |
| POST | /api/import |
导入(见第四节) |
| GET | /api/subscription |
查询当前学期的日历订阅地址 |
| GET | /ics/:token.ics |
公开日历订阅地址(免鉴权) |
| GET | /api/pub/calendar/:token.ics |
同上,等价别名 |
统一响应约定:成功 { ok:true, ... };失败 { ok:false, error:"描述", errors?:[...] }(HTTP 4xx/5xx)。常见状态码:201 创建成功、400 参数校验失败、401 未鉴权、404 资源不存在、422 导入存在行级错误。
示例:批量创建课程
POST /api/courses
X-API-Key: sk_xxx
Content-Type: application/json
[
{ "termId": "term_xxx", "name": "高等数学A", "teacher": "张伟", "location": "教三-301",
"dayOfWeek": 1, "startPeriod": 1, "endPeriod": 2, "weeks": "1-16周", "credit": 5 },
{ "termId": "term_xxx", "name": "数据结构", "dayOfWeek": 3,
"startPeriod": 3, "endPeriod": 4, "weeks": { "mode": "even", "start": 2, "end": 16 } }
]{ "ok": true, "count": 2, "items": [{ "id": "c_xxx", "...": "..." }] }系统提供两个等价的日历导出接口,区别仅在于是否需要 API Key:
| 用途 | 地址 | 鉴权 |
|---|---|---|
| ① 订阅型(推荐,手机自动同步) | GET /ics/{token}.ics |
无需鉴权,URL 中 token 即凭据 |
| ② 下载型(一次性导入) | GET /api/export?format=ics&termId=xxx |
需 X-API-Key |
获取 / 刷新地址:
GET /api/subscription?termId=term_xxx
X-API-Key: sk_xxx{
"ok": true,
"termId": "term_xxx",
"termName": "2026-2027学年 秋季学期",
"token": "EViYjuC8iqlSCDMa",
"urls": {
"subscribe": "http://172.16.0.76:3000/ics/EViYjuC8iqlSCDMa.ics",
"subscribeApi": "http://172.16.0.76:3000/api/pub/calendar/EViYjuC8iqlSCDMa.ics",
"download": "http://172.16.0.76:3000/api/export?format=ics&termId=term_xxx&api_key=<API_KEY>",
"recurrence": "http://172.16.0.76:3000/ics/EViYjuC8iqlSCDMa.ics?mode=recurrence&tz=floating",
"flat": "http://172.16.0.76:3000/ics/EViYjuC8iqlSCDMa.ics?mode=flat"
}
}订阅地址支持的参数(拼在 URL 后即可,无需改造 App):
| 参数 | 取值 | 用途 |
|---|---|---|
mode |
recurrence(默认)/ flat |
重复事件 vs 逐条事件(详见 7.4) |
tz |
floating(默认)/ tzid |
浮动本地时间 vs 带 VTIMEZONE 的标准时区 |
remind |
分钟数 | 覆盖默认提醒时长,0 关闭 |
title |
模板串 | 如 title={name}({teacher}) |
download |
1 |
加 Content-Disposition 触发下载而非订阅 |
示例调用:
curl -i "http://172.16.0.76:3000/ics/EViYjuC8iqlSCDMa.ics?mode=recurrence&remind=20"HTTP/1.1 200 OK
Content-Type: text/calendar; charset=utf-8; component=vevent; method=PUBLISH
Cache-Control: no-cache, no-store, must-revalidate
Content-Language: zh-CN
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//WorkBuddy//StudentTimetable 1.0//CN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:2026-2027学年 秋季学期 课表
X-WR-TIMEZONE:Asia/Shanghai
X-WR-CALDESC:2026-2027学年 秋季学期 自动同步课表,共 11 门课
程
REFRESH-INTERVAL;VALUE=DURATION:PT1H
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:c_feab6f640db3a02e@timetable.local
DTSTAMP:20261008T194007Z
DTSTART:20260907T080000
DTEND:20260907T094000
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;UNTIL=20261221T094000
SUMMARY:高等数学A(上)
LOCATION:教三-301
DESCRIPTION:课程:高等数学A(上)\n教师:张伟\n班级:计科2301\n周次:1-1
6周\n节次:第1-2节\n地点:教三-301\n学分:5
TRANSP:OPAQUE
STATUS:CONFIRMED
SEQUENCE:0
EXDATE:20261005T080000
BEGIN:VALARM
TRIGGER:-PT15M
ACTION:DISPLAY
DESCRIPTION:上课提醒:高等数学A(上)
END:VALARM
END:VEVENT
END:VCALENDAR语义映射:
| 课表概念 | ICS 映射 |
|---|---|
| 课程 | VEVENT(SUMMARY=课程名,可用 title 模板自定义) |
| 教师 / 班级 / 周次 / 学分数 | DESCRIPTION 多行文本 |
| 上课地点 | LOCATION |
| 每周重复 | RRULE:FREQ=WEEKLY;INTERVAL=n;BYDAY=xx;UNTIL=… |
| 单周 / 双周 | INTERVAL=2 且起始日期落在对应周(1-16周双 起始周为偶数周) |
| 离散周次 | 由 INTERVAL=1 + EXDATE 剔除不上课的周 |
| 放假 | EXDATE(日历端对应日期自动不显示) |
| 调休补课 | 独立的单次 VEVENT,UID 带 -mk-日期 后缀 |
| 提前提醒 | VALARM / TRIGGER:-PT15M / ACTION:DISPLAY |
| # | 要求 | 本系统实现 |
|---|---|---|
| 1 | 免鉴权可访问 | /ics/:token.ics 走独立白名单路由,不返回 401 WWW-Authenticate,避免小米日历无法完成挑战式认证 |
| 2 | 正确的 MIME | text/calendar; charset=utf-8; component=vevent; method=PUBLISH,兼容较挑剔的解析器 |
| 3 | UTF-8 无 BOM | Response body 为纯 UTF-8,不写 BOM(BOM 会导致 BEGIN:VCALENDAR 无法识别) |
| 4 | CRLF 行尾 + 75 字节折叠 | 严格 RFC 5545 §3.1;折叠按 UTF-8 字节切分且不切断中文字符,续行以单个空格开头(已由测试断言) |
| 5 | UID 稳定幂等 | UID = {课程id}@{域},不随内容变化;重复的增出不穷会导致小米日历每次刷新都重建事件与提醒 |
| 6 | 勿用 TZID(默认) | 默认输出浮动本地时间(DTSTART:20260907T080000,无 TZID/Z),规避国产 ROM TZID:Asia/Shanghai 解析不全导致的 ±8 小时偏移;需要标准写法时用 ?tz=tzid 切换(会附带 VTIMEZONE) |
| 7 | 单一 RRULE、避免 RDATE 泛滥 | 默认 recurrence 模式仅输出一条 RRULE + 少量 EXDATE,体积约为逐条展开的 1/7;对本学期 143 次课仅生成 13 个 VEVENT |
| 8 | 不使用过于复杂的规则 | 不使用 BYSETPOS/多个 BYDAY/RSCALE;选修/实验等非规律课程自动降级为独立事件 |
| 9 | 必须含 DTSTAMP | 使用 course.updatedAt,内容未变时保持不变,避免客户端误判为已修改而重刷 |
| 10 | 缓存控制 | Cache-Control: no-cache + REFRESH-INTERVAL:PT1H + X-PUBLISHED-TTL:PT1H,提示客户端按小时刷新 |
| 11 | URL 稳定性 | 令牌 16 位 base64url,不含特殊字符,便于手动输入;泄露后可 POST /api/terms/:id/token 立即轮换 |
| 12 | 网络可达 | 手机必须能与运行服务的机器互通(同一局域网 / 内网穿透 / 公网域名);建议配置 TIMETABLE_PUBLIC_BASE_URL 固定为你希望的地址 |
| 13 | HTTPS 可选 | 局域网 http:// 小米日历可直接订阅;公网场景建议 Nginx 反代 + HTTPS |
mode=recurrence(默认) |
mode=flat |
|
|---|---|---|
| 内容 | 每门课 1 个重复事件 + EXDATE | 每一次上课 1 个独立事件 |
| 体积(本示例学期 143 课次) | 13 个 VEVENT / ~7.6 KB | 143 个 VEVENT / ~35 KB |
| 修改后同步 | 立即生效 | 立即生效 |
| 兼容性 | 依赖 RRULE+EXDATE 支持 |
最强,任何日历 App 都能正确显示 |
| 适用 | 日常使用 / 流量敏感 | 个别机型出现「放假日期仍显示」或重复事件时改用 |
排查建议:若小米日历出现时间偏移 8 小时→改用默认
floating(已是默认);出现放假日期仍显示课程→改用?mode=flat;出现提醒重复/丢失→调整remind参数并重新添加订阅。
- 电脑与手机连到同一 Wi-Fi,关闭/放行电脑防火墙对应端口(或改用内网穿透 / 公网域名)。
- 打开管理端 → 右上角「日历订阅」→ 复制订阅地址。
- 手机「日历」App → 右上角
⋮→ 设置 → 日历订阅(或「添加订阅日历」)→ 粘贴地址 → 确定。 - 返回日历 → 右上角
⋮→ 勾选刚出现的日历名称。 - 下拉刷新可立即拉取;否则约 1 小时自动同步。
备选方案:管理端点「下载 .ics 文件」,或把
/api/export?format=ics的下载文件通过 QQ/微信传到手机后用「日历」App 打开导入(一次性导入,不会自动更新)。
timetable/
├── package.json
├── README.md # 本文档
├── LICENSE # MIT
├── .gitignore
├── src/
│ ├── server.js # HTTP 服务、路由、鉴权、CORS、静态资源
│ ├── store.js # JSON 持久化(含配置与 API Key)
│ ├── model.js # 字段定义、校验、周次规则、上课实例展开
│ ├── importers.js # JSON / CSV / ICS / URL 导入
│ ├── exporters.js # JSON / CSV / CSV周视图 / XLSX / ICS 导出
│ ├── ics.js # RFC5545 生成器(折叠、RRULE/EXDATE、VALARM)
│ ├── xlsx.js # 自实现 OOXML 写入器(含 CRC32/ZIP 打包)
│ └── util.js # 日期/CSV/CRC32 等工具
├── public/
│ ├── index.html # 管理端页面(响应式,适配手机)
│ ├── app.js # 管理端逻辑(周视图 / 日程列表双模式)
│ └── style.css # 样式(含 900 / 640 / 400px 断点)
├── docs/
│ ├── xiaomi-calendar-sync.md # 小米日历同步专项说明(含排障表)
│ └── sample-timetable.ics # 示例输出
├── scripts/
│ ├── seed.js # 示例数据
│ ├── smoke.js # 端到端测试(78 项)
│ ├── import-njpi.js # 教务系统直连导入(jwxt.njpi.edu.cn)
│ └── open-firewall.ps1# Windows 防火墙放行/回收端口
└── data/ # 运行时数据(db.json、config.json,已 gitignore)
npm test # node scripts/smoke.js覆盖:鉴权、学期 CRUD 与校验、Monday 对齐、课程 CRUD/批量/过滤、节假日语义、周视图与放假剔除、统计、五种导出格式、ICS 的 CRLF 与 75 字节折叠/RRULE/EXDATE/VALARM/VTIMEZONE、JSON/CSV/ICS/URL 四类导入与三种冲突策略、订阅地址与令牌轮换、级联删除、静态资源。当前结果:通过 78 项,失败 0 项。
- 存储为单文件 JSON(
data/db.json),适合单机/班级规模使用;生产环境如需多写并发,建议替换src/store.js为数据库实现(保持同名方法即可)。 - ICS 导入不支持全天事件(大学课表无此场景),会被跳过并在
details中说明。 - 若同一时段在同一天安排多门课,管理端会自动横向分列显示,不做冲突校验(教务场景允许选修冲突)。