rollcall-classroom-assets 1.0.4 → 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 +59 -11
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.4",
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();
@@ -8173,7 +8218,7 @@ const BC_MODE_KEY = 'rollcall_bc_mode';
8173
8218
  const BC_TIP = {
8174
8219
  thermal: '编号 = 班级号 + 姓氏拼音 A→Z 序号(35 班第 1 个 = 3501),全班固定不变。<b>热敏 50×20</b>:一张标签占一页,把打印机纸张设成 50×20 后直接连打,撕下贴作业本封面即可。',
8175
8220
  '30x10': '编号 = 班级号 + 姓氏拼音 A→Z 序号(35 班第 1 个 = 3501),全班固定不变。<b>热敏 30×10</b>:便携标签机常用小纸,一张占一页;上行印「编号 + 姓名」、下行是条码,把打印机纸张设成 30×10 后直接连打,撕下贴作业本角即可。',
8176
- a4: '编号 = 班级号 + 姓氏拼音 A→Z 序号(35 班第 1 个 = 3501),全班固定不变。<b>A4 拼版</b>:一页排 4 列,打印后沿虚线剪开,贴在作业本封面即可用扫码枪扫。',
8221
+ a4: '编号 = 班级号 + 姓氏拼音 A→Z 序号(35 班第 1 个 = 3501),全班固定不变。<b>A4 拼版</b>:一页 <b>4 列 × 14 行 = 56 格</b>(一个班 50 出头也能一页印完,不必跨页),打印后沿虚线剪开,贴作业本封面即可用扫码枪扫。',
8177
8222
  a4one: '编号 = 班级号 + 姓氏拼音 A→Z 序号(35 班第 1 个 = 3501),全班固定不变。<b>A4 · 一生一版</b>:一个孩子一张纸,11 行 × 3 列 = 33 张 TA 自己的 50×20 标签;<b>条码带已加高到 12.8mm</b>(贴书脊竖着更好扫),格与格之间留 6mm 空当,落剪刀好裁。'
8178
8223
  };
8179
8224
  // 打印方式白名单:thermal = 50×20、30x10 = 便携小标签、a4 = 拼版、a4one = A4 一生一版(11×4 同名)。
@@ -8181,11 +8226,13 @@ const BC_TIP = {
8181
8226
  const BC_MODES = ['thermal', '30x10', 'a4', 'a4one'];
8182
8227
  function normBcMode(v) { return BC_MODES.indexOf(String(v)) >= 0 ? String(v) : 'thermal'; }
8183
8228
  // @page 是**整篇文档级**规则,只能由 JS 按当前模式注入一条;三模式各自一张纸,集中在这里。
8184
- // 热敏纸都不吃页边距(标签本身就是成品尺寸),A4 才要留 8mm 走打印机不可打印区。
8229
+ // 热敏纸都不吃页边距(标签本身就是成品尺寸),A4 才要留边距走打印机不可打印区。
8185
8230
  const BC_PAGE = {
8186
8231
  thermal: '@page{size:50mm 20mm;margin:0}',
8187
8232
  '30x10': '@page{size:30mm 10mm;margin:0}',
8188
- a4: '@page{size:A4 portrait;margin:8mm}',
8233
+ // 拼版:8mm → 5mm(第 177 轮,用户:一页要装下整个班,最多 53 人)。
8234
+ // 5mm 下可用 200×287mm → 4 列 × 14 行 = 56 格,一页放下整班;原来 8mm/12 行 = 48 格,53 人要跨页。
8235
+ a4: '@page{size:A4 portrait;margin:5mm}',
8189
8236
  // 一生一版:5mm 页边距 → 可用宽 200mm = 4×50mm,正好贴满预裁标签纸
8190
8237
  a4one: '@page{size:A4 portrait;margin:5mm}'
8191
8238
  };
@@ -8231,12 +8278,13 @@ function renderBcSheet() {
8231
8278
  // (Code128 下限 0.25mm 的 1.97 倍),左右几乎无白边(静止区只留 2 模块)。
8232
8279
  // 上行 3mm + 0.15mm 间距 + 条码 6.15mm ≈ 9.3mm,10mm 纸内不跨页。
8233
8280
  // A4 拼版 :姓名 / 条码 / 编号 三行,剪开即用。第 176 轮:**条码不再写死 35mm 宽**,
8234
- // 改由 CSS width:100% 撑满格子(见 .mode-a4 .bc-svg),于是出码比例必须同步压扁
8235
- // —— h 40 → 35(viewBox 20 → 17.5 模块高):格子内宽 ≈44.6mm 时条码高 ≈11.8mm,
8236
- // 与一生一版那张图的条码带比例(48:12.8)一致,整格 ≈48×21mm,12 行/页不溢出。
8237
- // 若沿用 h=40,条码会涨到 13.5mm 高,整格 ≈22.3mm → 12 行 291mm 超一页。
8281
+ // 改由 CSS width:100% 撑满格子(见 .mode-a4 .bc-svg),于是出码比例必须同步压扁。
8282
+ // 第 177 轮:一页要装下整班(4 列 × 14 行 = 56 格)→ 行高被压到 ≈18.4mm,
8283
+ // 出码再压一档 h 35 → 30(viewBox 17.5 → 15 模块高):格子内宽 ≈45.3mm 时
8284
+ // 条码高 ≈10.3mm、模块宽 0.69mm(下限 0.25mm 的 2.7 倍),扫得动。
8285
+ // 若沿用 h=35,条码 11.5mm 高 → 整格 ≈19.6mm,14 行 296mm 超一页。
8238
8286
  sheet.innerHTML = items.map(x => bcMode === 'a4'
8239
- ? `<div class="bc-label"><div class="bc-nm" title="${esc(x.name)}">${esc(x.name)}</div>${code128Svg(x.code, { mw: 2, h: 35 })}<div class="bc-no">${esc(x.code)}</div></div>`
8287
+ ? `<div class="bc-label"><div class="bc-nm" title="${esc(x.name)}">${esc(x.name)}</div>${code128Svg(x.code, { mw: 2, h: 30 })}<div class="bc-no">${esc(x.code)}</div></div>`
8240
8288
  : `<div class="bc-label"><div class="bc-hd"><span class="bc-no">${esc(x.code)}</span> <span class="bc-nm" title="${esc(x.name)}">${esc(x.name)}</span></div>${code128Svg(x.code, { mw: 2, h: bcMode === '30x10' ? 25 : 40, quiet: bcMode === '30x10' ? 2 : 10 })}</div>`
8241
8289
  ).join('');
8242
8290
  applyBcPage();