三月份去实习帮云衢中学做了一套学生奖惩公示系统,跑在教学楼走廊的展示屏上。

普通的网页都好办,但因为那块屏是一台 Android 4.4 的老设备,导致很多新的前端方式都运用不上。

仓库:HG-dev17/yunqu · 线上:yunqu.cookiesky.cn

一、需求

现实中要解决的事很简单:

  1. 学校要公开表彰与批评,鼓励和警告学生的平时行为;
  2. 数据由德育处、年级处老师录,老师是不碰代码的,只能做后端;
  3. 换学期要能一键切换,历史记录要能查;
  4. 手机、旧平板、走廊那台老盒子,都得能打开。

二、技术选型

第一版就定了:展示端是纯静态页,数据放 CSV。

理由很实际:

  • 学校没有服务器,也没有人运维;
  • 静态托管免费、不会挂;
  • CSV 用 Excel 和 WPS 都能打开,老师看得懂;
  • 改一条记录 = 改一行文本,出问题能直接看出来。

数据目录长这样:

1
2
3
4
5
6
7
8
9
data/
├── semesters.csv # 学期索引:key,name,start_date,end_date,file
├── 2024-2025-1.csv # 每学期一个记录文件
├── 2024-2025-2.csv
├── 2025-2026-1.csv
├── 2025-2026-2.csv
├── announcement.csv # 公告(文本/图片/视频 + 适用年级)
├── countdown.csv # 倒计时配置
├── img/ video/ # 公告附件

semesters.csv 可以定义新的学期:

1
2
3
key,name,start_date,end_date,file
current_semester,2025-2026学年第二学期,2026-02-17,2026-07-15,2025-2026-2.csv
last_semester,2025-2026学年第一学期,2025-09-01,2026-01-20,2025-2026-1.csv

页面按今天的日期落在哪个区间,自动判断“当前学期”,再加载对应文件。这样换学期只需要改一行日期。

三、Android 4.4 的解决办法

第一次上机测试就翻了:白屏、滚不动、按钮错位。

原因是——那台设备的内核老到没有 fetch,没有 Promise,没有 async/await,连 Array.prototype.forEach 都要靠 polyfill 补。

于是整个 js/script.js 被改写成 ES5 + XHR 回调:

1
2
3
4
5
6
7
if (!Array.prototype.forEach) {
Array.prototype.forEach = function (callback, thisArg) {
var T, k;
if (this == null) throw new TypeError('this is null or not defined');
// ……标准 polyfill
};
}
1
2
3
4
5
6
7
8
9
10
11
function fetchText(url, cb) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300) cb(null, xhr.responseText);
else cb(new Error('HTTP ' + xhr.status));
}
};
xhr.send();
}

这带来两个连锁问题:

  • 回调地狱:学期索引 → 记录文件 → 公告 → 渲染,四层嵌套。解决办法是把每层拆成独立函数 + 一个 cb 参数,用命名函数而不是匿名嵌套;
  • 没有 AbortController:滚动/切学期时没法取消旧请求,只能在回调里校验“这是不是当前学期”,过期结果直接丢弃。

还有一个隐蔽的坑:BOM。Excel 另存的 CSV 是带 BOM 的 UTF-8,第一个字段会变成 \ufeffkey,对象取值永远 undefined。后来统一用:

1
2
3
function stripBOM(s) {
return s && s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
}

服务器导出时也统一写 encoding='utf-8-sig'。

四、展示端

大屏场景和网页场景完全不同:没有鼠标、没有触摸、也没人管它。

所以做了这些:

  • 奖惩榜自动滚动:整块列表匀速上移,到底部无缝回顶;
  • 公告栏自动轮播:多条公告按顺序播,可含图片和视频,视频播完自动下一条;
  • 暂停/重置:给维护老师留两个按钮,不被学生乱点;
  • 左右两栏:左奖惩、右公告,把 16:9 的屏用满(1.0.4.29 版本从上下布局改的);
  • 倒计时:直接读 countdown.csv,中考倒计时按”天”刷新。

自动滚动的实现没有用 CSS 动画(旧内核的 transform 动画会抖),而是 JS 定时器 + translateY,速度可调(scrollSpeed = 30)。手动滚动模式后来又单独修了一轮,解决“滚动留白和抽动”——根因是间隙高度算错,回顶时跳了一格。

五、公告栏

公告最初只是一段文字,后来老师的需求一条条加上来:

版本 加了什么
1.0.2.5 公告栏 + 公告管理界面
1.0.5.13 支持图片、视频;内容允许为空;图片点击放大
1.0.6.8 自动滚动、视频自动播放、按年级筛选

announcement.csv 就多了几列:

1
2
3
text,grade,image,video,time
"眼保健操的管理提醒……",初三,,,2026-03-30
,初二,goodclass.jpg,,2026-03-24

text 允许为空,只展示图片或视频——这一条看起来小,但它把”公告”从通知变成了展示板,老师可以直接贴流动红旗的照片。

六、管理端与本地服务

展示端是静态的,但录入需要一个能改数据的界面。

admin.html 直接读写同一批 CSV(配合本地起服务),all_semesters.html 做跨学期汇总。

真正做“在线可编辑”时,才加了 server.py:

1
2
3
4
5
6
Flask + SQLite
├── GET /api/semesters
├── POST /api/records
├── PUT /api/records/<id>
├── DELETE /api/records/<id>
└── GET /api/export/<semester> # 导出回 CSV

因为没有服务器,无法修改静态的网页,设计上选择了:数据库只做“编辑期的暂存”,最终导出CSV进行替换。

老师改完点导出 → 写回 data/*.csv → 提交仓库 → 静态站自动更新。

还加了一个”从 CSV 导入数据库”的逻辑,并且判断”该学期是否已有数据,有就跳过”,避免每次重启把老师的编辑覆盖掉。

七、版本日志

我把每次改动都记在 README 里,从 1.0.0.0 到 1.0.6.8,一共三十多次提交。回头看,真正花时间的地方几乎都不是功能:

  • 兼容旧内核(3 次紧急修复);
  • 时间格式(2026/3/18 和 2026-03-18 混用导致筛选全空);
  • 缓存(旧浏览器必须手动刷新才更新,最后给资源加版本参数解决);
  • 删除数据时弹三次确认框(事件重复绑定)。