rollcall-classroom-assets 1.0.0 → 1.0.2

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 (4) hide show
  1. package/c-app.js +363 -8
  2. package/dm-app.js +36 -0
  3. package/package.json +14 -10
  4. package/zuoye-app.js +9233 -9088
package/c-app.js CHANGED
@@ -31,6 +31,9 @@
31
31
  user: null, classes: [], cls: '', date: today(), tab: 'roll',
32
32
  roster: [], absent: {}, // 姓名 -> 'n'(缺) | 'l'(假);不在表里=到
33
33
  subjects: [], subject: '', marks: {}, // 收缴:姓名 -> 'y'|'n'
34
+ notes: {}, // 收缴:姓名 -> 备注(写给 /api/submit 的 notes 字段)
35
+ q: '', // 收缴:搜索词(姓名/拼音/编号)
36
+ rollFrame: false, rollLite: false, // 点名:是否加载完整点名小程序 / 用户是否改回简易点名
34
37
  qa: {} // 姓名 -> {ok:0, no:0}(本页累计,用于显示)
35
38
  };
36
39
 
@@ -93,6 +96,108 @@
93
96
  .catch(function () { var v = snapKey ? snap(snapKey) : null; return v ? Object.assign({ __offline: true }, v) : null; });
94
97
  }
95
98
 
99
+ // ================= 拼音(按需加载)+ 学生编号 =================
100
+ // ⚠️ 编号算法必须与批改页(zuoye-app.js 的 buildCodes)**逐字一致** —— 作业本上的条码按那套印,
101
+ // 差一点就「扫了没反应/标错人」。拼音库 315KB 只在真正用到编号或拼音搜索时才拉(按需加载)。
102
+ var pyReady = false, pyLoading = null, pyCache = {}, codeMap = {};
103
+ var pyStore = lsGet('c_py', {}) || {};
104
+ function loadPinyin() {
105
+ if (window.pinyinPro) { pyReady = true; return Promise.resolve(true); }
106
+ if (pyLoading) return pyLoading;
107
+ pyLoading = new Promise(function (res) {
108
+ var s = document.createElement('script');
109
+ s.src = '/pinyin-pro.js'; s.async = true;
110
+ s.onload = function () {
111
+ pyReady = !!window.pinyinPro;
112
+ if (pyReady) { buildPy(S.roster); buildCodes(S.roster); paintRep(); }
113
+ res(pyReady);
114
+ };
115
+ s.onerror = function () { pyLoading = null; res(false); };
116
+ document.body.appendChild(s);
117
+ });
118
+ return pyLoading;
119
+ }
120
+ function pyForms(n) { // 与批改页 pyForms 逐字一致
121
+ var pp = window.pinyinPro; if (!pp) return null;
122
+ try {
123
+ var arr = pp.pinyin(n, { toneType: 'none', type: 'array', v: true });
124
+ var ini = pp.pinyin(n, { pattern: 'first', toneType: 'none', type: 'array' });
125
+ var full = (Array.isArray(arr) ? arr.join('') : '').toLowerCase();
126
+ var init = (Array.isArray(ini) ? ini.join('') : '').toLowerCase();
127
+ var flex = '';
128
+ if (Array.isArray(arr) && arr.length >= 2) {
129
+ var t = '';
130
+ for (var i = 0; i < arr.length - 1; i++) t += (ini[i] || String(arr[i])[0] || '');
131
+ flex = (t + String(arr[arr.length - 1])).toLowerCase();
132
+ }
133
+ return { full: full, init: init, flex: flex };
134
+ } catch (e) { return null; }
135
+ }
136
+ function buildPy(names) {
137
+ names.forEach(function (n) {
138
+ if (pyCache[n]) return;
139
+ if (pyStore[n]) { pyCache[n] = pyStore[n]; return; }
140
+ var f = pyForms(n); if (f) { pyCache[n] = f; pyStore[n] = f; }
141
+ });
142
+ try { localStorage.setItem('c_py', JSON.stringify(pyStore)); } catch (e) { }
143
+ // 「拼音就绪」判定:有真库,**或**本机缓存已覆盖全班每一个人(缓存来自上次会话,同样是真拼音)。
144
+ // 任何一个人拿不到真拼音就保持不就绪 —— 编号宁可空着,也绝不错位(错位比不编号更难查)。
145
+ if (!pyReady) {
146
+ pyReady = names.length > 0 && names.every(function (n) { var p = pyCache[n]; return !!(p && p.full && p.full !== n); });
147
+ }
148
+ }
149
+ function pad2s(s) { s = String(s == null ? '' : s); return s.length < 2 ? ('0' + s) : s; } // 老浏览器没有 padStart
150
+ function clsNumPrefix(cls) { // 班级名里最后一组数字的后两位('35' / '七年级35班' → '35')
151
+ var m = String(cls || '').match(/\d+/g);
152
+ if (!m) return '';
153
+ return pad2s(m[m.length - 1].slice(-2));
154
+ }
155
+ function sortKeyOf(n) { var p = pyCache[n]; return p ? p.full : n; }
156
+ function buildCodes(names) { // 与批改页 buildCodes 逐字一致:整班拼音就绪才算,宁可空也不错位
157
+ codeMap = {};
158
+ if (!names || !names.length) return;
159
+ var pfx = clsNumPrefix(S.cls);
160
+ if (!pfx || !pyReady) return;
161
+ var ordered = names.slice().sort(function (x, y) { var kx = sortKeyOf(x), ky = sortKeyOf(y); return kx < ky ? -1 : kx > ky ? 1 : 0; });
162
+ for (var i = 0; i < ordered.length; i++) { var p = pyCache[ordered[i]]; if (!p || p.full === ordered[i]) return; }
163
+ ordered.forEach(function (n, i) { codeMap[n] = pfx + pad2s(i + 1); });
164
+ }
165
+ function codeOf(n) { return codeMap[n] || ''; }
166
+ function codeHit(code, q, pfx) { // 编号命中:完整/前缀,或只输序号后两位(01 也能命中 3501)
167
+ if (!code) return false;
168
+ var s = String(q == null ? '' : q);
169
+ if (!s) return false;
170
+ if (code.indexOf(s) === 0) return true;
171
+ var p = String(pfx == null ? '' : pfx);
172
+ if (!p || code.length <= p.length) return false;
173
+ return code.slice(p.length).indexOf(pad2s(s)) === 0;
174
+ }
175
+ function nameHit(n, q) { // 搜索:姓名 / 全拼 / 首字母 / 编号
176
+ var ql = String(q || '').trim().toLowerCase();
177
+ if (!ql) return true;
178
+ if (/^\d+$/.test(ql)) return codeHit(codeOf(n), ql, clsNumPrefix(S.cls));
179
+ if (n.toLowerCase().indexOf(ql) >= 0) return true;
180
+ var p = pyCache[n];
181
+ if (p) return (p.full.indexOf(ql) >= 0) || (p.init.indexOf(ql) === 0) || (p.flex.indexOf(ql) >= 0);
182
+ return false;
183
+ }
184
+ function filtered() { return S.roster.filter(function (n) { return nameHit(n, S.q); }); }
185
+ // 扫码(编号)→ 标已交。找不到就返回 false(绝不猜人)
186
+ function applyScan(raw) {
187
+ var code = String(raw || '').trim();
188
+ if (!/^\d+$/.test(code)) return false;
189
+ var pfx = clsNumPrefix(S.cls), hit = '';
190
+ S.roster.forEach(function (n) {
191
+ if (!hit && codeOf(n) && codeHit(codeOf(n), code, pfx)) hit = n;
192
+ });
193
+ if (!hit) return false;
194
+ var was = S.marks[hit] || '';
195
+ S.marks[hit] = 'y';
196
+ repTouch();
197
+ toast('✅ ' + hit + '(' + code + ')已标已交' + (was === 'y' ? '(原本就是)' : ''));
198
+ return true;
199
+ }
200
+
96
201
  // ================= 点名 =================
97
202
  function paintRoll() {
98
203
  var h = '';
@@ -139,24 +244,30 @@
139
244
 
140
245
  // ================= 收缴 =================
141
246
  function paintRep() {
247
+ var list = filtered();
142
248
  var h = '';
143
- S.roster.forEach(function (nm) {
144
- var mk = S.marks[nm] || '';
249
+ list.forEach(function (nm) {
250
+ var mk = S.marks[nm] || '', note = S.notes[nm] || '', cd = codeOf(nm);
145
251
  h += '<div class="row" data-mark="' + mk + '" data-nm="' + esc(nm) + '">' +
252
+ (cd ? '<span class="code" title="学生编号(扫码枪扫这个)">' + esc(cd) + '</span>' : '') +
146
253
  '<span class="nm">' + esc(nm) + '</span>' +
254
+ (note ? '<span class="note" title="' + esc(note) + '">' + esc(note) + '</span>' : '') +
255
+ '<button class="btn sm" data-note="1" title="备注">📝</button>' +
147
256
  '<button class="btn sm' + (mk === 'y' ? ' y' : '') + '" data-m="y">已交</button>' +
148
257
  '<button class="btn sm' + (mk === 'n' ? ' n' : '') + '" data-m="n">未交</button>' +
149
258
  '</div>';
150
259
  });
151
- $('repList').innerHTML = h || '<div class="hint">这个班还没有名单。</div>';
260
+ $('repList').innerHTML = h || '<div class="hint">' + (S.roster.length ? ('没有匹配「' + esc(S.q) + '」的同学。') : '这个班还没有名单。') + '</div>';
152
261
  var cy = 0, cn = 0;
153
262
  Object.keys(S.marks).forEach(function (k) { if (S.marks[k] === 'y') cy++; else if (S.marks[k] === 'n') cn++; });
154
- $('footNote').textContent = S.roster.length ? ('已交 ' + cy + ' · 未交 ' + cn + ' · 未标记 ' + (S.roster.length - cy - cn)) : '';
263
+ $('footNote').textContent = S.roster.length
264
+ ? ('已交 ' + cy + ' · 未交 ' + cn + ' · 未标记 ' + (S.roster.length - cy - cn) + (S.q ? ('(筛出 ' + list.length + ' 人)') : ''))
265
+ : '';
155
266
  }
156
267
  var repTimer = 0;
157
268
  function saveRep(quiet) {
158
269
  if (!S.cls || !S.subject) { if (!quiet) toast('请先选科目'); return; }
159
- var body = { cls: S.cls, date: S.date, subject: S.subject, marks: S.marks, notes: {}, noPush: true };
270
+ var body = { cls: S.cls, date: S.date, subject: S.subject, marks: S.marks, notes: S.notes, noPush: true };
160
271
  fetch('/api/submit', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
161
272
  .then(function (r) { return r.json().catch(function () { return {}; }); })
162
273
  .then(function (d) {
@@ -221,7 +332,11 @@
221
332
  sel.innerHTML = all.map(function (s) { return '<option' + (s === S.subject ? ' selected' : '') + '>' + esc(s) + '</option>'; }).join('');
222
333
  var it = (d && d.ok && d.items || []).filter(function (x) { return x.subject === S.subject; })[0];
223
334
  S.marks = (it && it.marks) ? Object.assign({}, it.marks) : {};
335
+ S.notes = (it && it.notes) ? Object.assign({}, it.notes) : {};
336
+ buildPy(S.roster); // 先用本机缓存的拼音算编号(零下载);没有缓存也不会去拉库
337
+ buildCodes(S.roster);
224
338
  paintRep();
339
+ scanHint();
225
340
  if (d && d.__offline) toast('📴 离线:用本机缓存的登记结果');
226
341
  });
227
342
  }
@@ -231,7 +346,7 @@
231
346
  ['roll', 'rep', 'track'].forEach(function (k) { $('pane-' + k).classList.toggle('on', k === t); });
232
347
  $('foot').style.display = t === 'track' ? 'none' : 'flex';
233
348
  $('allHere').textContent = t === 'rep' ? '全班已交' : '全部到齐';
234
- if (t === 'roll') loadRoll(); else if (t === 'rep') loadRep(); else loadTrack();
349
+ if (t === 'roll') openRoll(); else if (t === 'rep') loadRep(); else loadTrack();
235
350
  }
236
351
 
237
352
  // ---------- 事件 ----------
@@ -243,7 +358,11 @@
243
358
  getJSON('/api/submit?cls=' + encodeURIComponent(S.cls) + '&date=' + S.date, 'submit_' + S.cls + '_' + S.date).then(function (d) {
244
359
  var it = (d && d.ok && d.items || []).filter(function (x) { return x.subject === S.subject; })[0];
245
360
  S.marks = (it && it.marks) ? Object.assign({}, it.marks) : {};
361
+ S.notes = (it && it.notes) ? Object.assign({}, it.notes) : {};
362
+ buildPy(S.roster); // 先用本机缓存的拼音算编号(零下载);没有缓存也不会去拉库
363
+ buildCodes(S.roster);
246
364
  paintRep();
365
+ scanHint();
247
366
  });
248
367
  });
249
368
  Array.prototype.forEach.call(document.querySelectorAll('#tabs button'), function (b) { b.addEventListener('click', function () { showTab(b.dataset.t); }); });
@@ -257,8 +376,19 @@
257
376
  });
258
377
  $('repList').addEventListener('click', function (e) {
259
378
  var btn = e.target.closest('button'); if (!btn) return;
260
- var nm = btn.closest('.row').dataset.nm, m = btn.dataset.m, cur = S.marks[nm] || '';
261
- if (cur === m) delete S.marks[nm]; else S.marks[nm] = m; // 再点一次 = 取消
379
+ var row = btn.closest('.row'); if (!row) return;
380
+ var nm = row.dataset.nm;
381
+ if (btn.dataset.note) { // 📝 备注(存进 /api/submit 的 notes 字段)
382
+ var cur = S.notes[nm] || '';
383
+ var v = window.prompt('给「' + nm + '」写备注(可留空清除)', cur);
384
+ if (v === null) return;
385
+ v = String(v).slice(0, 60);
386
+ if (v) S.notes[nm] = v; else delete S.notes[nm];
387
+ repTouch();
388
+ return;
389
+ }
390
+ var m = btn.dataset.m, curm = S.marks[nm] || '';
391
+ if (curm === m) delete S.marks[nm]; else S.marks[nm] = m; // 再点一次 = 取消
262
392
  repTouch();
263
393
  });
264
394
  $('allHere').addEventListener('click', function () {
@@ -271,6 +401,195 @@
271
401
  $('save').addEventListener('click', function () { if (S.tab === 'track') return loadTrack(); if (S.tab === 'rep') { clearTimeout(repTimer); saveRep(false); } else saveAbsent(); });
272
402
  $('trackGo').addEventListener('click', loadTrack);
273
403
 
404
+ // ================= 点名:优先加载完整点名小程序(iframe + 免登录票) =================
405
+ // 为什么用 iframe:dm.html 有 108 个 id、自带整套样式,直接塞进本页会与教室页 id/CSS 打架;
406
+ // iframe 天然隔离。免登录靠跨域一次性兑换码:本页已登录 → POST /api/handoff 签票 →
407
+ // 把它当 iframe 的 src,dm 域核销后就是登录态(cookie 是 host-only,各域不通用,只能走这条)。
408
+ // 拿不到票 / 9 秒还没 load → 优雅退回原来的简易点名,绝不让老师卡在空白页。
409
+ function toLite() {
410
+ S.rollLite = true;
411
+ $('rollFrameWrap').style.display = 'none';
412
+ $('rollLiteWrap').style.display = '';
413
+ loadRoll();
414
+ }
415
+ function openRoll() {
416
+ if (S.rollLite) { toLite(); return; }
417
+ $('rollFrameWrap').style.display = '';
418
+ $('rollLiteWrap').style.display = 'none';
419
+ if (S.rollFrame) return; // 已载入过 → 直接显示(不重复签票)
420
+ var f = $('rollFrame'), done = false;
421
+ var fall = setTimeout(function () {
422
+ if (!done) { done = true; toast('点名小程序启动慢,先给你简易点名'); toLite(); }
423
+ }, 9000);
424
+ f.onload = function () { done = true; clearTimeout(fall); };
425
+ fetch('/api/handoff', {
426
+ method: 'POST', credentials: 'same-origin',
427
+ headers: { 'Content-Type': 'application/json' },
428
+ body: JSON.stringify({ to: 'dm', path: '/dm' })
429
+ })
430
+ .then(function (r) { return r.json(); })
431
+ .then(function (d) {
432
+ if (!d || !d.ok || !d.url) throw new Error('handoff');
433
+ S.rollFrame = true;
434
+ $('rollNewWin').href = d.url;
435
+ f.src = d.url;
436
+ })
437
+ .catch(function () {
438
+ if (done) return;
439
+ done = true; clearTimeout(fall);
440
+ toast('没能免登录打开点名小程序,已用简易点名');
441
+ toLite();
442
+ });
443
+ }
444
+
445
+ // ================= 收缴:搜索 / 扫码 / 备注 =================
446
+ function scanHint() {
447
+ var h = [];
448
+ if (!pyReady) h.push('编号与拼音搜索:首次用到时才加载拼音库(按需)');
449
+ else if (!Object.keys(codeMap).length) h.push('⚠ 这个班算不出编号(拼音或班级名不全),仍可用姓名搜索');
450
+ h.push('扫码枪:光标停在上面的搜索框,扫作业本条码(编号+回车)即标「已交」');
451
+ $('scanHint').textContent = h.join(' · ');
452
+ }
453
+ // —— 摄像头扫码:原生解码器优先(零下载),没有再按需拉 zxing(教室机是 Windows 桌面 Chrome,通常走后者)
454
+ var camOn = false, camStream = null, camTimer = 0, camCanvas = null, camLast = {};
455
+ var CAM_COOL = 1500;
456
+ function openScan() {
457
+ $('scan').classList.add('on');
458
+ $('scanTip').textContent = '正在准备…';
459
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
460
+ $('scanTip').textContent = '这个浏览器没有摄像头接口:可改用扫码枪(编号+回车),或换新版 Chrome';
461
+ return;
462
+ }
463
+ navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function (st) {
464
+ camStream = st; camOn = true;
465
+ var v = $('scanVideo');
466
+ v.srcObject = st;
467
+ try { var p = v.play(); if (p && p.catch) p.catch(function () { }); } catch (e) { }
468
+ startDecode();
469
+ }).catch(function (e) {
470
+ $('scanTip').textContent = '打不开摄像头(' + ((e && e.name) || '') + '):可改用扫码枪,或检查系统摄像头权限';
471
+ });
472
+ }
473
+ function closeScan() {
474
+ camOn = false; clearTimeout(camTimer);
475
+ try { if (camStream) camStream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) { }
476
+ camStream = null;
477
+ try { $('scanVideo').srcObject = null; } catch (e) { }
478
+ $('scan').classList.remove('on');
479
+ }
480
+ function onCamCode(raw) {
481
+ var code = String(raw || '').trim();
482
+ if (!/^\d+$/.test(code)) return; // 只认纯数字编号(与扫码枪一致)
483
+ var now = Date.now();
484
+ if (now - (camLast[code] || 0) < CAM_COOL) return; // 同码冷却,避免连帧重复
485
+ camLast[code] = now;
486
+ if (applyScan(code)) {
487
+ try { if (navigator.vibrate) navigator.vibrate(60); } catch (e) { }
488
+ $('scanTip').textContent = '✅ 已标记,继续扫下一个…';
489
+ } else {
490
+ $('scanTip').textContent = '读到 ' + code + ',但不在本班编号里(要先在批改页打印过该班条码)';
491
+ }
492
+ }
493
+ function startDecode() {
494
+ var v = $('scanVideo');
495
+ if (window.BarcodeDetector) { // ① 原生(安卓/macOS Chrome):零依赖
496
+ try {
497
+ var bd = new BarcodeDetector({ formats: ['code_128'] });
498
+ $('scanTip').textContent = '把作业本上的编号条码对准取景框…';
499
+ (function tick() {
500
+ if (!camOn) return;
501
+ if (v.videoWidth) {
502
+ try {
503
+ if (!camCanvas) camCanvas = document.createElement('canvas');
504
+ camCanvas.width = v.videoWidth; camCanvas.height = v.videoHeight;
505
+ camCanvas.getContext('2d').drawImage(v, 0, 0);
506
+ bd.detect(camCanvas).then(function (rs) {
507
+ (rs || []).forEach(function (r) { if (r && r.text) onCamCode(r.text); });
508
+ }).catch(function () { });
509
+ } catch (e) { }
510
+ }
511
+ camTimer = setTimeout(tick, 200);
512
+ })();
513
+ return;
514
+ } catch (e) { }
515
+ }
516
+ $('scanTip').textContent = '正在加载扫码引擎(首次约 0.9MB,之后走本机缓存)…';
517
+ loadZXing().then(function (Z) {
518
+ if (!camOn) return;
519
+ if (!Z) { $('scanTip').textContent = '扫码引擎加载失败:改用扫码枪(编号+回车)即可'; return; }
520
+ $('scanTip').textContent = '把作业本上的编号条码对准取景框…';
521
+ (function tick() {
522
+ if (!camOn) return;
523
+ if (v.videoWidth) {
524
+ try {
525
+ var sc = Math.min(1, 800 / v.videoWidth);
526
+ if (!camCanvas) camCanvas = document.createElement('canvas');
527
+ camCanvas.width = Math.max(8, Math.round(v.videoWidth * sc));
528
+ camCanvas.height = Math.max(8, Math.round(v.videoHeight * sc));
529
+ camCanvas.getContext('2d', { willReadFrequently: true }).drawImage(v, 0, 0, camCanvas.width, camCanvas.height);
530
+ Z.readBarcodes(camCanvas, { formats: zxFormats(Z), tryHarder: true }).then(function (rs) {
531
+ (rs || []).forEach(function (r) { if (r && r.text) onCamCode(r.text); });
532
+ }).catch(function () { });
533
+ } catch (e) { }
534
+ }
535
+ camTimer = setTimeout(tick, 140);
536
+ })();
537
+ });
538
+ }
539
+ var zxLoading = null;
540
+ function loadZXing() {
541
+ if (window.ZXingWASM && window.ZXingWASM.readBarcodes) return Promise.resolve(window.ZXingWASM);
542
+ if (zxLoading) return zxLoading;
543
+ try { if (typeof WebAssembly !== 'object') return Promise.resolve(null); } catch (e) { return Promise.resolve(null); }
544
+ zxLoading = new Promise(function (res) {
545
+ var fin = false;
546
+ function done(z) { if (fin) return; fin = true; res(z); }
547
+ var s = document.createElement('script');
548
+ s.src = '/zxing-wasm-reader.js'; s.async = true;
549
+ s.onload = function () { done((window.ZXingWASM && window.ZXingWASM.readBarcodes) ? window.ZXingWASM : null); };
550
+ s.onerror = function () { zxLoading = null; done(null); };
551
+ document.body.appendChild(s);
552
+ setTimeout(function () { done((window.ZXingWASM && window.ZXingWASM.readBarcodes) ? window.ZXingWASM : null); }, 12000);
553
+ });
554
+ return zxLoading;
555
+ }
556
+ function zxFormats(Z) {
557
+ try { var f = Z.barcodeFormats || Z.READABLE_BARCODE_FORMATS || {}; return [f.Code128 || f.CODE_128 || 'code_128']; }
558
+ catch (e) { return []; }
559
+ }
560
+ function ensureCodes(then) { // 编号就绪后才谈扫码定位;拼音库按需拉
561
+ return loadPinyin().then(function () {
562
+ buildPy(S.roster);
563
+ buildCodes(S.roster);
564
+ paintRep(); scanHint();
565
+ if (then) then();
566
+ });
567
+ }
568
+
569
+ // ---------- 收缴增强的事件 ----------
570
+ $('repScan').addEventListener('click', function () { ensureCodes(openScan); });
571
+ $('scanClose').addEventListener('click', closeScan);
572
+ $('repFind').addEventListener('input', function () {
573
+ var raw = this.value || '';
574
+ S.q = raw.trim();
575
+ if (/[a-zA-Z]/.test(raw)) ensureCodes(null); else { paintRep(); scanHint(); }
576
+ });
577
+ $('repFind').addEventListener('keydown', function (e) {
578
+ if (e.key !== 'Enter' && e.keyCode !== 13) return;
579
+ var v = (this.value || '').trim();
580
+ if (!/^\d+$/.test(v)) return; // 非纯数字 → 普通搜索,不当作扫码
581
+ e.preventDefault();
582
+ var input = this;
583
+ ensureCodes(function () {
584
+ if (!applyScan(v)) toast('编号 ' + v + ' 不在本班名单里');
585
+ input.value = ''; S.q = ''; paintRep(); scanHint();
586
+ });
587
+ });
588
+ $('repFindClr').addEventListener('click', function () {
589
+ $('repFind').value = ''; S.q = ''; paintRep(); scanHint(); $('repFind').focus();
590
+ });
591
+ $('rollLite').addEventListener('click', function (e) { e.preventDefault(); toLite(); });
592
+
274
593
  // ---------- 启动 ----------
275
594
  $('lb').addEventListener('click', function () {
276
595
  var u = $('lu').value.trim(), p = $('lp').value;
@@ -319,3 +638,39 @@
319
638
  }
320
639
  boot();
321
640
  })();
641
+
642
+ // === [NC-CRED-PATCH v24] 离线同步凭据:同源 JSON 变更请求自动带 {__u,__h} ===
643
+ // 服务端(resolveInlineCred)由同一次请求同时完成「验权 + 上传」—— 断网/弱网批改后补传时,
644
+ // 不再要求先有会话 cookie(会话可能早过期了)。另:任一变更成功后,顺手用本地存的 hash
645
+ // 换一次真会话(/api/login 的凭据升级通道),之后的 GET 也就不再 401。
646
+ (function () {
647
+ try {
648
+ var cred = null; try { cred = JSON.parse(localStorage.getItem('nc_cred') || 'null'); } catch (e) { }
649
+ if (!cred || !cred.u || !cred.h) return;
650
+ var upgraded = false;
651
+ function upgrade() {
652
+ if (upgraded) return; upgraded = true;
653
+ try {
654
+ fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: cred.u, password: cred.h }) }).catch(function () { });
655
+ } catch (e) { }
656
+ }
657
+ var of = window.fetch; if (!of) return;
658
+ window.fetch = function (input, init) {
659
+ try {
660
+ var m = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
661
+ var u = (typeof input === 'string') ? input : ((input && input.url) || '');
662
+ var isApi = (u.indexOf('/api/') === 0) || (u.indexOf(location.origin + '/api/') === 0);
663
+ if (m !== 'GET' && m !== 'HEAD' && isApi && init && typeof init.body === 'string') {
664
+ var b = null; try { b = JSON.parse(init.body); } catch (e) { }
665
+ if (b && typeof b === 'object') { b.__u = cred.u; b.__h = cred.h; init.body = JSON.stringify(b); }
666
+ }
667
+ } catch (e) { }
668
+ var p = of.apply(this, arguments);
669
+ try {
670
+ var m2 = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
671
+ if (m2 !== 'GET' && m2 !== 'HEAD') { p.then(function (r) { if (r && r.ok) upgrade(); }).catch(function () { }); }
672
+ } catch (e) { }
673
+ return p;
674
+ };
675
+ } catch (e) { }
676
+ })();
package/dm-app.js CHANGED
@@ -1985,3 +1985,39 @@ if (window.self !== window.top) {
1985
1985
  loadAbsent();
1986
1986
  })();
1987
1987
  // === [ABSENT-QUIZ 模块终点] ===
1988
+
1989
+ // === [NC-CRED-PATCH v24] 离线同步凭据:同源 JSON 变更请求自动带 {__u,__h} ===
1990
+ // 服务端(resolveInlineCred)由同一次请求同时完成「验权 + 上传」—— 断网/弱网批改后补传时,
1991
+ // 不再要求先有会话 cookie(会话可能早过期了)。另:任一变更成功后,顺手用本地存的 hash
1992
+ // 换一次真会话(/api/login 的凭据升级通道),之后的 GET 也就不再 401。
1993
+ (function () {
1994
+ try {
1995
+ var cred = null; try { cred = JSON.parse(localStorage.getItem('nc_cred') || 'null'); } catch (e) { }
1996
+ if (!cred || !cred.u || !cred.h) return;
1997
+ var upgraded = false;
1998
+ function upgrade() {
1999
+ if (upgraded) return; upgraded = true;
2000
+ try {
2001
+ fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: cred.u, password: cred.h }) }).catch(function () { });
2002
+ } catch (e) { }
2003
+ }
2004
+ var of = window.fetch; if (!of) return;
2005
+ window.fetch = function (input, init) {
2006
+ try {
2007
+ var m = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
2008
+ var u = (typeof input === 'string') ? input : ((input && input.url) || '');
2009
+ var isApi = (u.indexOf('/api/') === 0) || (u.indexOf(location.origin + '/api/') === 0);
2010
+ if (m !== 'GET' && m !== 'HEAD' && isApi && init && typeof init.body === 'string') {
2011
+ var b = null; try { b = JSON.parse(init.body); } catch (e) { }
2012
+ if (b && typeof b === 'object') { b.__u = cred.u; b.__h = cred.h; init.body = JSON.stringify(b); }
2013
+ }
2014
+ } catch (e) { }
2015
+ var p = of.apply(this, arguments);
2016
+ try {
2017
+ var m2 = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
2018
+ if (m2 !== 'GET' && m2 !== 'HEAD') { p.then(function (r) { if (r && r.ok) upgrade(); }).catch(function () { }); }
2019
+ } catch (e) { }
2020
+ return p;
2021
+ };
2022
+ } catch (e) { }
2023
+ })();
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
- {
2
- "name": "rollcall-classroom-assets",
3
- "version": "1.0.0",
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
- "keywords": ["assets", "static", "cdn"],
6
- "license": "MIT",
7
- "publishConfig": {
8
- "registry": "https://registry.npmjs.org/"
9
- }
10
- }
1
+ {
2
+ "name": "rollcall-classroom-assets",
3
+ "version": "1.0.2",
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
+ "keywords": [
6
+ "assets",
7
+ "static",
8
+ "cdn"
9
+ ],
10
+ "license": "MIT",
11
+ "publishConfig": {
12
+ "registry": "https://registry.npmjs.org/"
13
+ }
14
+ }