rollcall-classroom-assets 1.0.5 → 1.0.7

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 +98 -8
  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());
@@ -323,12 +331,31 @@ function rosterDelList() {
323
331
  }
324
332
 
325
333
  // 名单服务端同步:自定义班单名增删(服务端 class_data 权威)
334
+ // ★ 2026-10-14 复核 L2:这条原来是 fire-and-forget(只 .catch(() => {})),而服务端现在有四种
335
+ // 「没写成」:403 没权限 / 409 撞上乐观锁(名单刚被别的老师改了)/ 507 整行超字节 / 网络断。
336
+ // 不回滚就出现最难查的那种问题:老师看见名字已经加上了,刷一次页面它又不见了 ——
337
+ // 界面在说一件服务端并不知道的事。现在失败就把这一次内存改动收回去,
338
+ // 并把服务端那句人话原样显示出来(错误文案单点在 src/index.ts,别在这儿另编一套)。
326
339
  function rosterSync(api, cls, name) {
340
+ const undo = () => {
341
+ const arr = dynRoster[cls] = dynRoster[cls] || [];
342
+ if (api === 'add-student') { const i = arr.indexOf(name); if (i >= 0) arr.splice(i, 1); }
343
+ else if (!arr.includes(name)) arr.push(name);
344
+ try { afterRosterChange(''); } catch (e) { }
345
+ };
327
346
  fetch(`/api/${api}`, {
328
347
  method: 'POST',
329
348
  headers: { 'Content-Type': 'application/json' },
330
349
  body: JSON.stringify({ class: cls, name })
331
- }).catch(() => {});
350
+ }).then(async r => {
351
+ let d = null; try { d = await r.json(); } catch (e) { }
352
+ if (d && d.ok) return;
353
+ undo();
354
+ rosterToast('⚠️ 名单没存上:' + ((d && d.error) || ('HTTP ' + r.status)) + '(刚才那次改动已收回,可重做一次)');
355
+ }).catch(() => {
356
+ undo();
357
+ rosterToast('⚠️ 名单没存上(连不上服务器),刚才那次改动已收回 —— 重做一次或等网络恢复');
358
+ });
332
359
  }
333
360
  // 内置班权威名单全量上传(KV class_data[cls] 就是名单本身,批改页 /api/roster 直接读它)
334
361
  // 串行排队:一次操作可能触发多次上传(批量添加等),排队保证服务端最终状态 = 最后一次全量名单
@@ -1054,23 +1081,66 @@ function updateSyncIndicator() {
1054
1081
  }
1055
1082
 
1056
1083
  // 批量同步所有待处理记录
1084
+ // —— 攒批上报(2026-10-14 容量整改)——
1085
+ // 批量接口早就有了(record-qa-batch 一次收一整批),但每个 ✓/✗ 都**立刻**触发一次发送:
1086
+ // 一节 53 人的课 = 53 次跨境往返(实测单次 TTFB 0.8~1.5 秒),也是全站最容易省的请求量。
1087
+ // 现在:攒够 QA_FLUSH_N 条或等 QA_FLUSH_MS 毫秒发一批;切后台/关页面**立即冲刷**,
1088
+ // 绝不为了攒批把老师的点名结果留在 localStorage 里。服务端按 class+name+result+time 去重,
1089
+ // 所以冲刷后没读到响应(页面被冻结)也不要紧 —— 下次打开重发同一批,不会产生重复记录。
1090
+ const QA_FLUSH_N = 5, QA_FLUSH_MS = 3000;
1091
+ let qaFlushTimer = null, qaSending = false, qaDeferredFlush = false;
1092
+ function requestQASync() {
1093
+ const n = getPendingQAs().length;
1094
+ if (n === 0) return;
1095
+ if (n >= QA_FLUSH_N) { flushQASync(); return; }
1096
+ if (!qaFlushTimer) qaFlushTimer = setTimeout(() => { qaFlushTimer = null; flushQASync(); }, QA_FLUSH_MS);
1097
+ }
1098
+ function flushQASync() {
1099
+ if (qaFlushTimer) { clearTimeout(qaFlushTimer); qaFlushTimer = null; }
1100
+ if (qaSending) { qaDeferredFlush = true; return; } // 记一下:上一批落下来时补发,别把这次请求吞了
1101
+ syncPendingQAs();
1102
+ }
1103
+ // 切后台/关页面前冲刷。★ visibilitychange 挂在 **document** 上(规范里它在 document 触发,
1104
+ // 而且不冒泡)—— 挂在 window 上收不到,测试第一版就是这么漏掉的;pagehide 才在 window。
1105
+ // 走 flushQASync 而不是直接调 syncPendingQAs:上一批还在飞时后者会被发送闸挡掉,
1106
+ // 挡掉又不能不管 —— 这正是「切后台了却什么都没发出去」的那条路。
1107
+ try {
1108
+ document.addEventListener('visibilitychange', () => {
1109
+ if (!document.hidden) return;
1110
+ if (getPendingQAs().length > 0) flushQASync();
1111
+ }, { passive: true });
1112
+ } catch (e) { /* 老环境没有这个事件,不阻塞点名 */ }
1113
+ try {
1114
+ window.addEventListener('pagehide', () => {
1115
+ if (getPendingQAs().length > 0) flushQASync();
1116
+ }, { passive: true });
1117
+ } catch (e) { }
1118
+
1057
1119
  async function syncPendingQAs() {
1058
1120
  let pending = getPendingQAs();
1059
1121
  if (pending.length === 0) return;
1122
+ if (qaSending) return;
1123
+ qaSending = true;
1124
+ setTimeout(() => { qaSending = false; }, 8000); // 兜底:响应回调丢了也别把发送闸永久锁住
1060
1125
  // 收集后通过批量 API 一次性提交
1061
1126
  const batch = pending.map(p => ({ class: p.class, name: p.name, result: p.result, time: p.time, ...(p.content ? { content: p.content } : {}) }));
1062
1127
  try {
1063
1128
  const r = await fetch('/api/record-qa-batch', {
1064
1129
  method: 'POST',
1065
1130
  headers: { 'Content-Type': 'application/json' },
1131
+ // keepalive:切后台/关页面那一次的冲刷要能在页面被冻结后仍然发出去
1132
+ keepalive: true,
1066
1133
  body: JSON.stringify(batch)
1067
1134
  });
1068
1135
  const data = await r.json();
1069
1136
  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}`));
1137
+ // 只从队列里摘掉【这一批发出去的】那些条目 —— 必须重新读一遍当前队列。
1138
+ // 以前是拿进来时那份 pending 快照算 remaining 再整体写回:上一批在飞的这几百毫秒里
1139
+ // 老师新点的记录(不在快照里)会被这次写回一起清掉,本机、服务器两头都没有 —— 静默丢点名结果。
1140
+ // 服务端按 class+name+result+time 去重,saved 为实际新增数;即便 saved=0(全部已存在)
1141
+ // 也应摘除,避免无限重试。
1142
+ const sentSet = new Set(batch.map(e => `${e.class}|${e.name}|${e.result}|${e.time}`));
1143
+ const remaining = getPendingQAs().filter(p => !sentSet.has(`${p.class}|${p.name}|${p.result}|${p.time}`));
1074
1144
  savePendingQAs(remaining);
1075
1145
  if (remaining.length === 0) {
1076
1146
  if (data.saved > 0) {
@@ -1080,6 +1150,9 @@ async function syncPendingQAs() {
1080
1150
  } else {
1081
1151
  delToastText.textContent = `⚠️ 同步 ${data.saved} 条,剩余 ${remaining.length} 条待重试`;
1082
1152
  setTimeout(updateSyncIndicator, 2000);
1153
+ // 送成功的这批落地后,队列里还剩(多半就是上面那几百毫秒里新点的)—— 继续排一波。
1154
+ // 只在成功路径上排:失败已有 10 秒退避,这里无条件重排会在服务端挂掉时变成密集重试。
1155
+ requestQASync();
1083
1156
  }
1084
1157
  return;
1085
1158
  }
@@ -1088,6 +1161,15 @@ async function syncPendingQAs() {
1088
1161
  updateSyncIndicator();
1089
1162
  // 超时/失败后等 10 秒再试
1090
1163
  setTimeout(syncPendingQAs, 10000);
1164
+ } finally {
1165
+ qaSending = false; // 发送闸必须在两条路径上都放开,否则点名会「看起来不再同步」
1166
+ // 上一批在飞时收到的冲刷请求(切后台、满 5 条、改判连点)在这里补一次。
1167
+ // 只在「确实有人请求过」时补 —— 无条件按剩余量重发会绕开上面那个 10 秒退避,
1168
+ // 服务端挂了就会变成密集重试风暴。
1169
+ if (qaDeferredFlush) {
1170
+ qaDeferredFlush = false;
1171
+ if (getPendingQAs().length > 0) syncPendingQAs();
1172
+ }
1091
1173
  }
1092
1174
  }
1093
1175
 
@@ -1105,8 +1187,8 @@ function recordQA(resultType) {
1105
1187
  delToastText.textContent = `📝 已记录:${name} - ${label}${content ? `(${content})` : ''}`;
1106
1188
  setTimeout(() => updateSyncIndicator(), 1500);
1107
1189
 
1108
- // 2. 后台同步到服务器
1109
- syncPendingQAs();
1190
+ // 2. 后台同步到服务器(攒批:见 requestQASync)
1191
+ requestQASync();
1110
1192
 
1111
1193
  // 3. 实时名单面板(开着时:本地 pending 即时渲染 + 稍后网络校准)
1112
1194
  if (livePanel.classList.contains('show')) {
@@ -1329,7 +1411,7 @@ function flipQaResult(name, target) {
1329
1411
  renderLivePanel();
1330
1412
  delToastText.textContent = `↔️ 已改判:${name} → ${target === 'correct' ? '✓ 合格' : '✗ 不合格'}`;
1331
1413
  setTimeout(() => updateSyncIndicator(), 1500);
1332
- syncPendingQAs();
1414
+ requestQASync(); // 改判也走攒批(连点改判时一次上报)
1333
1415
  scheduleLiveRefresh();
1334
1416
  }
1335
1417
 
@@ -1619,6 +1701,14 @@ async function bootstrap() {
1619
1701
  } else {
1620
1702
  selectClass(myClasses[0]);
1621
1703
  }
1704
+ // ⚠️ 会话有效性旁路核对(2026-10-13 P0-6):__BOOT__ 是上次有效会话时注入的快照,
1705
+ // SW 在网络差时可能把它喂给会话已过期的浏览器 —— 界面全绿,保存却全 401。
1706
+ (async () => {
1707
+ try {
1708
+ const r = await fetch('/api/me', { cache: 'no-store' });
1709
+ if (r.status === 401) obSessionBar();
1710
+ } catch (e) { /* 网络不通:离线模式自有兜底 */ }
1711
+ })();
1622
1712
  const pending = getPendingQAs();
1623
1713
  if (pending.length > 0) setTimeout(syncPendingQAs, 2000);
1624
1714
  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.7",
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();