rollcall-classroom-assets 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dm-app.js +78 -7
  2. package/package.json +1 -1
  3. package/zuoye-app.js +48 -3
package/dm-app.js CHANGED
@@ -143,6 +143,14 @@ const lpPass = document.getElementById('lpPass');
143
143
  const lpFail = document.getElementById('lpFail');
144
144
 
145
145
  // --- 班级按钮(按当前账号任教班级动态生成) ---
146
+ function obSessionBar() { // 会话过期横幅:SW 喂了陈旧副本时,别让老师对着绿界面干点不动
147
+ if (document.getElementById('obSessionBar')) return;
148
+ const b = document.createElement('div');
149
+ b.id = 'obSessionBar';
150
+ b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#8a2b0f;color:#fff;text-align:center;font-size:13px;padding:5px 10px;';
151
+ b.innerHTML = '登录已过期,保存不会生效 —— <a href="/login.html" style="color:#fff;text-decoration:underline">点此重新登录</a>';
152
+ (document.body || document.documentElement).appendChild(b);
153
+ }
146
154
  function buildClassBtns() {
147
155
  classBar.querySelectorAll('.boot-tip').forEach(e => e.remove()); // 移除首屏「正在加载名单…」占位
148
156
  classBar.querySelectorAll('.class-btn.class-cls').forEach(b => b.remove());
@@ -1054,23 +1062,66 @@ function updateSyncIndicator() {
1054
1062
  }
1055
1063
 
1056
1064
  // 批量同步所有待处理记录
1065
+ // —— 攒批上报(2026-10-14 容量整改)——
1066
+ // 批量接口早就有了(record-qa-batch 一次收一整批),但每个 ✓/✗ 都**立刻**触发一次发送:
1067
+ // 一节 53 人的课 = 53 次跨境往返(实测单次 TTFB 0.8~1.5 秒),也是全站最容易省的请求量。
1068
+ // 现在:攒够 QA_FLUSH_N 条或等 QA_FLUSH_MS 毫秒发一批;切后台/关页面**立即冲刷**,
1069
+ // 绝不为了攒批把老师的点名结果留在 localStorage 里。服务端按 class+name+result+time 去重,
1070
+ // 所以冲刷后没读到响应(页面被冻结)也不要紧 —— 下次打开重发同一批,不会产生重复记录。
1071
+ const QA_FLUSH_N = 5, QA_FLUSH_MS = 3000;
1072
+ let qaFlushTimer = null, qaSending = false, qaDeferredFlush = false;
1073
+ function requestQASync() {
1074
+ const n = getPendingQAs().length;
1075
+ if (n === 0) return;
1076
+ if (n >= QA_FLUSH_N) { flushQASync(); return; }
1077
+ if (!qaFlushTimer) qaFlushTimer = setTimeout(() => { qaFlushTimer = null; flushQASync(); }, QA_FLUSH_MS);
1078
+ }
1079
+ function flushQASync() {
1080
+ if (qaFlushTimer) { clearTimeout(qaFlushTimer); qaFlushTimer = null; }
1081
+ if (qaSending) { qaDeferredFlush = true; return; } // 记一下:上一批落下来时补发,别把这次请求吞了
1082
+ syncPendingQAs();
1083
+ }
1084
+ // 切后台/关页面前冲刷。★ visibilitychange 挂在 **document** 上(规范里它在 document 触发,
1085
+ // 而且不冒泡)—— 挂在 window 上收不到,测试第一版就是这么漏掉的;pagehide 才在 window。
1086
+ // 走 flushQASync 而不是直接调 syncPendingQAs:上一批还在飞时后者会被发送闸挡掉,
1087
+ // 挡掉又不能不管 —— 这正是「切后台了却什么都没发出去」的那条路。
1088
+ try {
1089
+ document.addEventListener('visibilitychange', () => {
1090
+ if (!document.hidden) return;
1091
+ if (getPendingQAs().length > 0) flushQASync();
1092
+ }, { passive: true });
1093
+ } catch (e) { /* 老环境没有这个事件,不阻塞点名 */ }
1094
+ try {
1095
+ window.addEventListener('pagehide', () => {
1096
+ if (getPendingQAs().length > 0) flushQASync();
1097
+ }, { passive: true });
1098
+ } catch (e) { }
1099
+
1057
1100
  async function syncPendingQAs() {
1058
1101
  let pending = getPendingQAs();
1059
1102
  if (pending.length === 0) return;
1103
+ if (qaSending) return;
1104
+ qaSending = true;
1105
+ setTimeout(() => { qaSending = false; }, 8000); // 兜底:响应回调丢了也别把发送闸永久锁住
1060
1106
  // 收集后通过批量 API 一次性提交
1061
1107
  const batch = pending.map(p => ({ class: p.class, name: p.name, result: p.result, time: p.time, ...(p.content ? { content: p.content } : {}) }));
1062
1108
  try {
1063
1109
  const r = await fetch('/api/record-qa-batch', {
1064
1110
  method: 'POST',
1065
1111
  headers: { 'Content-Type': 'application/json' },
1112
+ // keepalive:切后台/关页面那一次的冲刷要能在页面被冻结后仍然发出去
1113
+ keepalive: true,
1066
1114
  body: JSON.stringify(batch)
1067
1115
  });
1068
1116
  const data = await r.json();
1069
1117
  if (data.ok) {
1070
- // 从队列移除本次已提交的条目(服务端已按 class+name+result+time 去重:
1071
- // saved 为实际新增数;即便 saved=0(全部已存在)也应清空 pending,避免无限重试)
1072
- const savedSet = new Set(batch.map(e => `${e.class}|${e.name}|${e.result}|${e.time}`));
1073
- const remaining = pending.filter(p => !savedSet.has(`${p.class}|${p.name}|${p.result}|${p.time}`));
1118
+ // 只从队列里摘掉【这一批发出去的】那些条目 —— 必须重新读一遍当前队列。
1119
+ // 以前是拿进来时那份 pending 快照算 remaining 再整体写回:上一批在飞的这几百毫秒里
1120
+ // 老师新点的记录(不在快照里)会被这次写回一起清掉,本机、服务器两头都没有 —— 静默丢点名结果。
1121
+ // 服务端按 class+name+result+time 去重,saved 为实际新增数;即便 saved=0(全部已存在)
1122
+ // 也应摘除,避免无限重试。
1123
+ const sentSet = new Set(batch.map(e => `${e.class}|${e.name}|${e.result}|${e.time}`));
1124
+ const remaining = getPendingQAs().filter(p => !sentSet.has(`${p.class}|${p.name}|${p.result}|${p.time}`));
1074
1125
  savePendingQAs(remaining);
1075
1126
  if (remaining.length === 0) {
1076
1127
  if (data.saved > 0) {
@@ -1080,6 +1131,9 @@ async function syncPendingQAs() {
1080
1131
  } else {
1081
1132
  delToastText.textContent = `⚠️ 同步 ${data.saved} 条,剩余 ${remaining.length} 条待重试`;
1082
1133
  setTimeout(updateSyncIndicator, 2000);
1134
+ // 送成功的这批落地后,队列里还剩(多半就是上面那几百毫秒里新点的)—— 继续排一波。
1135
+ // 只在成功路径上排:失败已有 10 秒退避,这里无条件重排会在服务端挂掉时变成密集重试。
1136
+ requestQASync();
1083
1137
  }
1084
1138
  return;
1085
1139
  }
@@ -1088,6 +1142,15 @@ async function syncPendingQAs() {
1088
1142
  updateSyncIndicator();
1089
1143
  // 超时/失败后等 10 秒再试
1090
1144
  setTimeout(syncPendingQAs, 10000);
1145
+ } finally {
1146
+ qaSending = false; // 发送闸必须在两条路径上都放开,否则点名会「看起来不再同步」
1147
+ // 上一批在飞时收到的冲刷请求(切后台、满 5 条、改判连点)在这里补一次。
1148
+ // 只在「确实有人请求过」时补 —— 无条件按剩余量重发会绕开上面那个 10 秒退避,
1149
+ // 服务端挂了就会变成密集重试风暴。
1150
+ if (qaDeferredFlush) {
1151
+ qaDeferredFlush = false;
1152
+ if (getPendingQAs().length > 0) syncPendingQAs();
1153
+ }
1091
1154
  }
1092
1155
  }
1093
1156
 
@@ -1105,8 +1168,8 @@ function recordQA(resultType) {
1105
1168
  delToastText.textContent = `📝 已记录:${name} - ${label}${content ? `(${content})` : ''}`;
1106
1169
  setTimeout(() => updateSyncIndicator(), 1500);
1107
1170
 
1108
- // 2. 后台同步到服务器
1109
- syncPendingQAs();
1171
+ // 2. 后台同步到服务器(攒批:见 requestQASync)
1172
+ requestQASync();
1110
1173
 
1111
1174
  // 3. 实时名单面板(开着时:本地 pending 即时渲染 + 稍后网络校准)
1112
1175
  if (livePanel.classList.contains('show')) {
@@ -1329,7 +1392,7 @@ function flipQaResult(name, target) {
1329
1392
  renderLivePanel();
1330
1393
  delToastText.textContent = `↔️ 已改判:${name} → ${target === 'correct' ? '✓ 合格' : '✗ 不合格'}`;
1331
1394
  setTimeout(() => updateSyncIndicator(), 1500);
1332
- syncPendingQAs();
1395
+ requestQASync(); // 改判也走攒批(连点改判时一次上报)
1333
1396
  scheduleLiveRefresh();
1334
1397
  }
1335
1398
 
@@ -1619,6 +1682,14 @@ async function bootstrap() {
1619
1682
  } else {
1620
1683
  selectClass(myClasses[0]);
1621
1684
  }
1685
+ // ⚠️ 会话有效性旁路核对(2026-10-13 P0-6):__BOOT__ 是上次有效会话时注入的快照,
1686
+ // SW 在网络差时可能把它喂给会话已过期的浏览器 —— 界面全绿,保存却全 401。
1687
+ (async () => {
1688
+ try {
1689
+ const r = await fetch('/api/me', { cache: 'no-store' });
1690
+ if (r.status === 401) obSessionBar();
1691
+ } catch (e) { /* 网络不通:离线模式自有兜底 */ }
1692
+ })();
1622
1693
  const pending = getPendingQAs();
1623
1694
  if (pending.length > 0) setTimeout(syncPendingQAs, 2000);
1624
1695
  return; // 服务端注入数据已完整,无需再拉 API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollcall-classroom-assets",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Static assets for the classroom workbench (roll-call): app JS, pinyin/xlsx/zxing engines and icons. Published only so they can be served from a China-friendly CDN.",
5
5
  "keywords": [
6
6
  "assets",
package/zuoye-app.js CHANGED
@@ -1339,6 +1339,14 @@ function obBadge(n) { // 持久徽标:队列非空就常驻,不像 flashAu
1339
1339
  }
1340
1340
  obChip.textContent = '📴 待补传 ' + n + ' 条';
1341
1341
  }
1342
+ function obSessionBar() { // 会话过期横幅:SW 喂了陈旧副本时,别让老师对着绿界面干点不动
1343
+ if (document.getElementById('obSessionBar')) return;
1344
+ const b = document.createElement('div');
1345
+ b.id = 'obSessionBar';
1346
+ b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#8a2b0f;color:#fff;text-align:center;font-size:13px;padding:5px 10px;';
1347
+ b.innerHTML = '登录已过期,保存不会生效 —— <a href="/login.html" style="color:#fff;text-decoration:underline">点此重新登录</a>';
1348
+ (document.body || document.documentElement).appendChild(b);
1349
+ }
1342
1350
  function obOfflineBar() { // 离线模式横幅:开着页面断网时也能一眼看到
1343
1351
  let b = document.getElementById('obOfflineBar');
1344
1352
  if (b) return;
@@ -2069,12 +2077,40 @@ function buildDateChip(it){
2069
2077
  });
2070
2078
  return c;
2071
2079
  }
2080
+ // —— 历史记录范围(左栏日期芯片往回取几天)——
2081
+ // 服务端只接受 1~7 / 14(其余回落 7)。为什么要窗口:`zuoye_<老师>_<班>_<日期>` 一学期就是几百个键,
2082
+ // 以前 /api/zuoye/history 是「list 全部 + 每 20 个一批 get」,SQL 条数随学期线性增长,
2083
+ // 而免费计划每次 invocation 只允许 50 条 D1 查询(超了直接报错)。
2084
+ // 现在窗口内是一段连续键区间 → 服务端一条范围读拿回全部值(见 src/d1store.ts 的 getRange)。
2085
+ const HIST_DAYS_ALLOWED = [1, 2, 3, 4, 5, 6, 7, 14];
2086
+ let histDays = (() => {
2087
+ let v;
2088
+ try { v = parseInt(localStorage.getItem('zy_hist_days') || '7', 10); } catch (e) { v = 7; }
2089
+ return HIST_DAYS_ALLOWED.indexOf(v) >= 0 ? v : 7;
2090
+ })();
2091
+ function histUrl(cls) {
2092
+ return `/api/zuoye/history?cls=${encodeURIComponent(cls)}&days=${histDays}`;
2093
+ }
2094
+ function wireHistDays() {
2095
+ const sel = $('histDaysSel');
2096
+ if (!sel || sel.__wired) return;
2097
+ sel.__wired = 1;
2098
+ sel.value = String(histDays);
2099
+ sel.onchange = () => {
2100
+ const v = parseInt(sel.value, 10);
2101
+ histDays = HIST_DAYS_ALLOWED.indexOf(v) >= 0 ? v : 7;
2102
+ sel.value = String(histDays);
2103
+ try { localStorage.setItem('zy_hist_days', String(histDays)); } catch (e) { }
2104
+ loadHistory(); // 换范围就立刻重拉,别让老师以为要点两下
2105
+ };
2106
+ }
2072
2107
  async function loadHistory() {
2108
+ wireHistDays();
2073
2109
  const h = $('dateList');
2074
2110
  h.innerHTML = '<span class="hist-empty">加载历史…</span>';
2075
2111
  closeDatePop();
2076
2112
  try {
2077
- const r = await (await fetch(`/api/zuoye/history?cls=${encodeURIComponent(curCls)}`)).json();
2113
+ const r = await (await fetch(histUrl(curCls))).json();
2078
2114
  histories = r.ok ? (r.dates || []) : [];
2079
2115
  } catch(e){ histories = []; }
2080
2116
  $('moreDatesBtn').style.display = 'none';
@@ -3201,11 +3237,11 @@ async function openDatePicker(cls) {
3201
3237
  const chipsBox = $('dpChips');
3202
3238
  const titleEl = $('dpTitle');
3203
3239
  if (!overlay || !chipsBox || !titleEl) return;
3204
- titleEl.textContent = `${cls}班 · 选择日期`;
3240
+ titleEl.textContent = `${cls}班 · 选择日期(近 ${histDays} 天)`;
3205
3241
  chipsBox.innerHTML = '<span class="dp-empty">加载历史记录…</span>';
3206
3242
  overlay.classList.add('on');
3207
3243
  try {
3208
- const r = await (await fetch(`/api/zuoye/history?cls=${encodeURIComponent(cls)}`)).json();
3244
+ const r = await (await fetch(histUrl(cls))).json();
3209
3245
  const dates = r.ok ? (r.dates || []) : [];
3210
3246
  if (!dates.length) { chipsBox.innerHTML = '<span class="dp-empty">该班暂无历史记录</span>'; return; }
3211
3247
  chipsBox.innerHTML = '';
@@ -3301,6 +3337,15 @@ async function bootstrap() {
3301
3337
  saveBtn.disabled = shareBtn.disabled = true;
3302
3338
  return;
3303
3339
  }
3340
+ // ⚠️ 会话有效性旁路核对(2026-10-13 P0-6):__BOOT__ 是上次**有效会话**时注入的快照,
3341
+ // SW 在网络差时可能把它喂给一个会话已过期的浏览器 —— 界面全绿,保存却全 401。
3342
+ // 不阻塞渲染地并行核对一次:401 就挂横幅明说,别让老师对着一片绿干点不动。
3343
+ (async () => {
3344
+ try {
3345
+ const r = await fetch('/api/me', { cache: 'no-store' });
3346
+ if (r.status === 401) obSessionBar();
3347
+ } catch (e) { /* 网络不通:离线模式自有兜底,不打扰 */ }
3348
+ })();
3304
3349
  setEditReady(false);
3305
3350
  bookLoad();
3306
3351
  const kick = () => schedulePrefetch();