rollcall-classroom-assets 0.0.0-stage → 1.0.0

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.
Binary file
package/c-app.js ADDED
@@ -0,0 +1,321 @@
1
+ /* 教室页 c.19860103.xyz —— 只为老旧教室电脑
2
+ *
3
+ * 需求(用户原话):「教室端上课打开的时候不需要扫码,不需要 excel,甚至不需要拼音,只需两项:
4
+ * 点名页完成当堂点名、作业批改的汇总页当堂完善现场交作业的同学,还有跟踪页。」
5
+ *
6
+ * 所以这里**刻意什么都不引**:无 pinyin-pro、无 xlsx、无 zxing/wasm、不加载 dm-app.js(92KB) 与
7
+ * zuoye-app.js(544KB)。整站只有本文件 + c.html。目标:首访线上不到 15KB(3KB/s 的链路上也就几秒)。
8
+ *
9
+ * 三个页签:
10
+ * 📋 点名 —— 到/缺/假 + 课堂回答(答对/答错各记一条),走 /api/absent 与 /api/record-qa-batch
11
+ * ✅ 收缴 —— 逐个点「已交/未交」,走 /api/submit(PUT 覆盖式,与课代表那套同一条数据)
12
+ * 📉 跟踪 —— 未交跟踪,走 /api/track
13
+ * 离线:同一套「先落本机、联网补传」的队列(absent / qa / submit 三种操作)。
14
+ */
15
+ (function () {
16
+ 'use strict';
17
+ var $ = function (id) { return document.getElementById(id); };
18
+ var NL = String.fromCharCode(10);
19
+
20
+ // ---------- 小工具 ----------
21
+ function lsGet(k, d) { try { var v = JSON.parse(localStorage.getItem(k) || ''); return v === null || v === undefined ? d : v; } catch (e) { return d; } }
22
+ function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { } }
23
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]; }); }
24
+ function today() { var d = new Date(Date.now() + 8 * 3600e3); return d.toISOString().slice(0, 10); }
25
+ var toastT = 0;
26
+ function showLogin(msg) { $('login').style.display = 'block'; if (msg) $('le').textContent = msg; }
27
+ function toast(m) { var t = $('toast'); t.textContent = m; t.classList.add('on'); clearTimeout(toastT); toastT = setTimeout(function () { t.classList.remove('on'); }, 2200); }
28
+
29
+ // ---------- 状态 ----------
30
+ var S = {
31
+ user: null, classes: [], cls: '', date: today(), tab: 'roll',
32
+ roster: [], absent: {}, // 姓名 -> 'n'(缺) | 'l'(假);不在表里=到
33
+ subjects: [], subject: '', marks: {}, // 收缴:姓名 -> 'y'|'n'
34
+ qa: {} // 姓名 -> {ok:0, no:0}(本页累计,用于显示)
35
+ };
36
+
37
+ // ---------- 离线补传队列(三种操作共用一条) ----------
38
+ var OB = 'c_outbox';
39
+ function obLoad() { var a = lsGet(OB, []); return a && a.length !== undefined ? a : []; }
40
+ function obSave(a) { try { a.length ? localStorage.setItem(OB, JSON.stringify(a)) : localStorage.removeItem(OB); } catch (e) { } paintStatus(); }
41
+ function obKey(it) { return it.kind === 'qa' ? ('qa' + (it.body[0] ? it.body[0].class + it.body[0].name + it.body[0].result : '')) : (it.kind + '|' + it.cls + '|' + it.date + '|' + (it.subj || '')); }
42
+ function obPush(kind, cls, date, body, subj) {
43
+ var a = obLoad();
44
+ if (kind !== 'qa') { // absent / submit 都是「整表覆盖」→ 同目标旧条目作废 // absent / submit 都是「整表覆盖」→ 同目标旧条目作废
45
+ for (var i = a.length - 1; i >= 0; i--) if (obKey(a[i]) === obKey({ kind: kind, cls: cls, date: date, subj: subj })) a.splice(i, 1);
46
+ }
47
+ if (kind === 'qa') { for (var j = 0; j < body.length; j++) a.push({ kind: 'qa', cls: cls, date: date, body: [body[j]], tries: 0 }); }
48
+ else a.push({ kind: kind, cls: cls, date: date, subj: subj || '', body: body, tries: 0 });
49
+ while (a.length > 400) a.shift();
50
+ obSave(a);
51
+ toast('📴 网络不通,已存本机,联网后自动补传(待传 ' + a.length + ' 条)');
52
+ }
53
+ var obBusy = false;
54
+ function obFlush() {
55
+ if (obBusy) return Promise.resolve();
56
+ var a = obLoad(); if (!a.length) return Promise.resolve();
57
+ obBusy = true;
58
+ return (function next() {
59
+ var cur = obLoad(); if (!cur.length) { obBusy = false; paintStatus(); return; }
60
+ var it = cur[0];
61
+ var url = it.kind === 'absent' ? '/api/absent' : (it.kind === 'qa' ? '/api/record-qa-batch' : '/api/submit');
62
+ var method = it.kind === 'submit' ? 'PUT' : 'POST';
63
+ return fetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(it.body) })
64
+ .then(function (r) { return r.json().catch(function () { return {}; }); })
65
+ .then(function (d) {
66
+ if (d && d.ok) { var b = obLoad(); b.shift(); obSave(b); if (it.kind === 'submit') S.saved = true; return next(); }
67
+ it.tries = (it.tries || 0) + 1;
68
+ if (it.tries >= 8 || (d && d.error === 'forbidden')) { // 硬拒/试太多次 → 丢弃并说明,不无限重试
69
+ var b2 = obLoad(); b2.shift(); obSave(b2);
70
+ toast('⚠ 一条补传被服务器拒绝已丢弃:' + (it.kind === 'qa' ? '答题记录' : it.kind + ' ' + it.cls));
71
+ return next();
72
+ }
73
+ var b3 = obLoad(); b3[0] = it; obSave(b3);
74
+ obBusy = false; paintStatus();
75
+ })
76
+ .catch(function () { obBusy = false; paintStatus(); }); // 网络不通 → 停下等下次
77
+ })();
78
+ }
79
+ window.addEventListener('online', function () { $('off').style.display = 'none'; obFlush(); });
80
+
81
+ // ---------- 状态栏 ----------
82
+ function paintStatus() {
83
+ var n = obLoad().length;
84
+ $('status').textContent = n ? ('📴 待补传 ' + n + ' 条') : '';
85
+ $('off').style.display = (navigator.onLine === false) ? 'block' : 'none';
86
+ }
87
+
88
+ // ---------- 通用取数(带本机兜底快照) ----------
89
+ function snap(k, v) { if (v !== undefined) lsSet('c_snap_' + k, { v: v, t: Date.now() }); return (lsGet('c_snap_' + k, null) || {}).v; }
90
+ function getJSON(url, snapKey) {
91
+ return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.json(); })
92
+ .then(function (d) { if (d && d.ok && snapKey) snap(snapKey, d); return d; })
93
+ .catch(function () { var v = snapKey ? snap(snapKey) : null; return v ? Object.assign({ __offline: true }, v) : null; });
94
+ }
95
+
96
+ // ================= 点名 =================
97
+ function paintRoll() {
98
+ var h = '';
99
+ S.roster.forEach(function (nm) {
100
+ var st = S.absent[nm] || '';
101
+ var q = S.qa[nm] || { ok: 0, no: 0 };
102
+ h += '<div class="row' + (st ? ' miss' : '') + '" data-nm="' + esc(nm) + '">' +
103
+ '<span class="nm">' + esc(nm) + '</span>' +
104
+ (q.ok || q.no ? '<span class="cnt">✔' + q.ok + ' ✘' + q.no + '</span>' : '') +
105
+ '<button class="btn sm' + (st === '' ? ' y' : '') + '" data-a="here">到</button>' +
106
+ '<button class="btn sm' + (st === 'n' ? ' n' : '') + '" data-a="miss">缺</button>' +
107
+ '<button class="btn sm' + (st === 'l' ? ' a' : '') + '" data-a="leave">假</button>' +
108
+ '<button class="btn sm" data-a="ok">✔答对</button>' +
109
+ '<button class="btn sm" data-a="no">✘答错</button>' +
110
+ '</div>';
111
+ });
112
+ $('rollList').innerHTML = h || '<div class="hint">这个班还没有名单。</div>';
113
+ var un = S.roster.filter(function (n) { return !S.absent[n]; }).length;
114
+ $('footNote').textContent = S.roster.length ? ('共 ' + S.roster.length + ' 人 · 缺/假 ' + (S.roster.length - un) + ' 人') : '';
115
+ }
116
+ function saveAbsent() {
117
+ if (!S.cls) return;
118
+ var names = Object.keys(S.absent);
119
+ var body = { cls: S.cls, date: S.date, names: names };
120
+ fetch('/api/absent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
121
+ .then(function (r) { return r.json().catch(function () { return {}; }); })
122
+ .then(function (d) {
123
+ if (d && d.ok) { toast('✅ 点名已保存(缺/假 ' + names.length + ' 人)'); return; }
124
+ if (d && d.error === 'forbidden') { toast('这个账号没有「缺勤报备」权限(只有老师/班主任/管理员可报)'); return; }
125
+ obPush('absent', S.cls, S.date, body, '');
126
+ })
127
+ .catch(function () { obPush('absent', S.cls, S.date, body, ''); });
128
+ }
129
+ function pushQA(nm, result) {
130
+ S.qa[nm] = S.qa[nm] || { ok: 0, no: 0 };
131
+ if (result === 'correct') S.qa[nm].ok++; else S.qa[nm].no++;
132
+ paintRoll();
133
+ var row = { class: S.cls, name: nm, result: result, time: new Date().toISOString() };
134
+ fetch('/api/record-qa-batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([row]) })
135
+ .then(function (r) { return r.json().catch(function () { return {}; }); })
136
+ .then(function (d) { if (!d || !d.ok) obPush('qa', S.cls, S.date, [row], ''); })
137
+ .catch(function () { obPush('qa', S.cls, S.date, [row], ''); });
138
+ }
139
+
140
+ // ================= 收缴 =================
141
+ function paintRep() {
142
+ var h = '';
143
+ S.roster.forEach(function (nm) {
144
+ var mk = S.marks[nm] || '';
145
+ h += '<div class="row" data-mark="' + mk + '" data-nm="' + esc(nm) + '">' +
146
+ '<span class="nm">' + esc(nm) + '</span>' +
147
+ '<button class="btn sm' + (mk === 'y' ? ' y' : '') + '" data-m="y">已交</button>' +
148
+ '<button class="btn sm' + (mk === 'n' ? ' n' : '') + '" data-m="n">未交</button>' +
149
+ '</div>';
150
+ });
151
+ $('repList').innerHTML = h || '<div class="hint">这个班还没有名单。</div>';
152
+ var cy = 0, cn = 0;
153
+ 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)) : '';
155
+ }
156
+ var repTimer = 0;
157
+ function saveRep(quiet) {
158
+ 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 };
160
+ fetch('/api/submit', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
161
+ .then(function (r) { return r.json().catch(function () { return {}; }); })
162
+ .then(function (d) {
163
+ if (d && d.ok) { if (!quiet) toast('✅ 已保存「' + S.subject + '」'); return; }
164
+ if (d && d.error === 'forbidden') { toast('没有登记该班的权限'); return; }
165
+ obPush('submit', S.cls, S.date, body, S.subject);
166
+ })
167
+ .catch(function () { obPush('submit', S.cls, S.date, body, S.subject); });
168
+ }
169
+ function repTouch() { paintRep(); clearTimeout(repTimer); repTimer = setTimeout(function () { saveRep(true); }, 1200); }
170
+
171
+ // ================= 跟踪 =================
172
+ function paintTrack(d) {
173
+ if (!d || !d.ok) { $('trackBox').innerHTML = '<div class="hint">读取失败' + (d && d.error === 'forbidden' ? ':没有该班权限' : '(网络?)') + '</div>'; return; }
174
+ var rows = (d.rows || []).slice().sort(function (a, b) { return (b.miss + b.bad) - (a.miss + a.bad) || a.name.localeCompare(b.name); });
175
+ var h = '<table><thead><tr><th>姓名</th><th>未交</th><th>不合格</th><th>提问不合格</th><th>最近</th></tr></thead><tbody>';
176
+ rows.forEach(function (r) {
177
+ var bad = (r.miss + r.bad) > 0 ? ((r.miss + r.bad) >= 3 ? 'bad0' : 'bad1') : '';
178
+ h += '<tr class="' + bad + '"><td>' + esc(r.name) + '</td><td class="n">' + r.miss + '</td><td class="n">' + r.bad + '</td><td class="n">' + r.qa + '</td><td>' + esc(r.last || '') + '</td></tr>';
179
+ });
180
+ h += '</tbody></table>';
181
+ $('trackBox').innerHTML = h;
182
+ $('trackNote').textContent = (d.from && d.to) ? (d.from + ' ~ ' + d.to + ' · 名单 ' + d.roster + ' 人' + (d.scoped ? '(只看' + d.scoped + ')' : '')) : '';
183
+ }
184
+ function loadTrack() {
185
+ if (!S.cls) return;
186
+ var days = $('days').value;
187
+ $('trackNote').textContent = '读取中…';
188
+ getJSON('/api/track?cls=' + encodeURIComponent(S.cls) + '&days=' + days, 'track_' + S.cls + '_' + days).then(paintTrack);
189
+ }
190
+
191
+ // ================= 载入与切换 =================
192
+ function loadRoll() {
193
+ if (!S.cls) return;
194
+ $('rollList').innerHTML = '<div class="hint">读取中…</div>';
195
+ Promise.all([
196
+ getJSON('/api/roster?cls=' + encodeURIComponent(S.cls), 'roster_' + S.cls),
197
+ getJSON('/api/absent?cls=' + encodeURIComponent(S.cls) + '&date=' + S.date, 'absent_' + S.cls + '_' + S.date)
198
+ ]).then(function (rs) {
199
+ var rn = rs[0] && rs[0].ok ? (rs[0].names || []) : [];
200
+ S.roster = rn.length ? rn : (snap('roster_' + S.cls) || []);
201
+ var ab = (rs[1] && rs[1].ok) ? (rs[1].names || []) : (snap('absent_' + S.cls + '_' + S.date) || []);
202
+ S.absent = {};
203
+ ab.forEach(function (n) { S.absent[n] = 'n'; });
204
+ paintRoll();
205
+ if (rs[1] && rs[1].__offline) toast('📴 离线:用本机缓存的点名结果');
206
+ });
207
+ }
208
+ function loadRep() {
209
+ if (!S.cls) return;
210
+ $('repList').innerHTML = '<div class="hint">读取中…</div>';
211
+ getJSON('/api/submit?cls=' + encodeURIComponent(S.cls) + '&date=' + S.date, 'submit_' + S.cls + '_' + S.date).then(function (d) {
212
+ var roster = (d && d.ok && d.roster) ? d.roster : (snap('roster_' + S.cls) || S.roster);
213
+ S.roster = roster.length ? roster : S.roster;
214
+ var subs = (d && d.ok && d.subjects) ? d.subjects : [];
215
+ // 科目默认值优先级:本机上次用的 → 自己的任教科目 → 服务端候选 → 兜底。
216
+ // ⚠️ 断网首次进来时拿不到候选,绝不能凭空造一个科目名写进服务器(会落到别的科目键上)。
217
+ if (!S.subject) S.subject = lsGet('c_subj', '') || (S.user && S.user.subject) || subs[0] || '作业';
218
+ S.subjects = subs.length ? subs : [S.subject];
219
+ var sel = $('subj');
220
+ var all = S.subjects.indexOf(S.subject) < 0 ? [S.subject].concat(S.subjects) : S.subjects;
221
+ sel.innerHTML = all.map(function (s) { return '<option' + (s === S.subject ? ' selected' : '') + '>' + esc(s) + '</option>'; }).join('');
222
+ var it = (d && d.ok && d.items || []).filter(function (x) { return x.subject === S.subject; })[0];
223
+ S.marks = (it && it.marks) ? Object.assign({}, it.marks) : {};
224
+ paintRep();
225
+ if (d && d.__offline) toast('📴 离线:用本机缓存的登记结果');
226
+ });
227
+ }
228
+ function showTab(t) {
229
+ S.tab = t;
230
+ Array.prototype.forEach.call(document.querySelectorAll('#tabs button'), function (b) { b.classList.toggle('on', b.dataset.t === t); });
231
+ ['roll', 'rep', 'track'].forEach(function (k) { $('pane-' + k).classList.toggle('on', k === t); });
232
+ $('foot').style.display = t === 'track' ? 'none' : 'flex';
233
+ $('allHere').textContent = t === 'rep' ? '全班已交' : '全部到齐';
234
+ if (t === 'roll') loadRoll(); else if (t === 'rep') loadRep(); else loadTrack();
235
+ }
236
+
237
+ // ---------- 事件 ----------
238
+ $('cls').addEventListener('change', function () { S.cls = this.value; lsSet('c_cls', S.cls); S.subject = ''; S.absent = {}; S.marks = {}; showTab(S.tab); });
239
+ $('date').addEventListener('change', function () { S.date = this.value || today(); lsSet('c_date', S.date); showTab(S.tab); });
240
+ $('subj').addEventListener('change', function () {
241
+ S.subject = this.value;
242
+ lsSet('c_subj', S.subject);
243
+ getJSON('/api/submit?cls=' + encodeURIComponent(S.cls) + '&date=' + S.date, 'submit_' + S.cls + '_' + S.date).then(function (d) {
244
+ var it = (d && d.ok && d.items || []).filter(function (x) { return x.subject === S.subject; })[0];
245
+ S.marks = (it && it.marks) ? Object.assign({}, it.marks) : {};
246
+ paintRep();
247
+ });
248
+ });
249
+ Array.prototype.forEach.call(document.querySelectorAll('#tabs button'), function (b) { b.addEventListener('click', function () { showTab(b.dataset.t); }); });
250
+ $('rollList').addEventListener('click', function (e) {
251
+ var btn = e.target.closest('button'); if (!btn) return;
252
+ var nm = btn.closest('.row').dataset.nm, a = btn.dataset.a;
253
+ if (a === 'ok') return pushQA(nm, 'correct');
254
+ if (a === 'no') return pushQA(nm, 'incorrect');
255
+ if (a === 'here') delete S.absent[nm]; else if (a === 'miss') S.absent[nm] = 'n'; else S.absent[nm] = 'l';
256
+ paintRoll();
257
+ });
258
+ $('repList').addEventListener('click', function (e) {
259
+ 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; // 再点一次 = 取消
262
+ repTouch();
263
+ });
264
+ $('allHere').addEventListener('click', function () {
265
+ if (S.tab === 'rep') { S.roster.forEach(function (n) { S.marks[n] = 'y'; }); repTouch(); return; }
266
+ S.absent = {}; paintRoll();
267
+ });
268
+ $('repAllY').addEventListener('click', function () { S.roster.forEach(function (n) { S.marks[n] = 'y'; }); repTouch(); });
269
+ $('repAllN').addEventListener('click', function () { S.roster.forEach(function (n) { S.marks[n] = 'n'; }); repTouch(); });
270
+ $('repClear').addEventListener('click', function () { S.marks = {}; repTouch(); });
271
+ $('save').addEventListener('click', function () { if (S.tab === 'track') return loadTrack(); if (S.tab === 'rep') { clearTimeout(repTimer); saveRep(false); } else saveAbsent(); });
272
+ $('trackGo').addEventListener('click', loadTrack);
273
+
274
+ // ---------- 启动 ----------
275
+ $('lb').addEventListener('click', function () {
276
+ var u = $('lu').value.trim(), p = $('lp').value;
277
+ if (!u || !p) { $('le').textContent = '请输入用户名和密码'; return; }
278
+ $('lb').disabled = true; $('lb').textContent = '登录中…'; $('le').textContent = '';
279
+ fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) })
280
+ .then(function (r) { return r.json(); })
281
+ .then(function (d) { if (d && d.ok) { location.reload(); return; } $('le').textContent = (d && d.error) || '登录失败'; $('lb').disabled = false; $('lb').textContent = '登 录'; })
282
+ .catch(function () { $('le').textContent = '网络错误,请重试'; $('lb').disabled = false; $('lb').textContent = '登 录'; });
283
+ });
284
+ function boot() {
285
+ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(function () { });
286
+ $('date').value = S.date = lsGet('c_date', today()) || today();
287
+ if (navigator.onLine === false) $('off').style.display = 'block';
288
+ paintStatus();
289
+ fetch('/api/me', { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (me) {
290
+ if (!me || !me.ok || !me.username) { showLogin(''); return; } // 未登录 → 本页自己登录(c 域独立签票)
291
+ S.user = me;
292
+ // 记住账号与班级:断网开机时用它把页面起起来(首次仍必须联网登录一次)
293
+ lsSet('c_me', { ok: true, username: me.username, role: me.role, classes: (me.classes || []).slice(), headClass: me.headClass || '', repClass: me.repClass || '', subject: me.subject || '' });
294
+ try { if (navigator.serviceWorker && navigator.serviceWorker.controller) navigator.serviceWorker.controller.postMessage({ type: 'who', user: me.username }); } catch (e) { }
295
+ var cls = (me.classes || []).slice();
296
+ if (me.headClass) cls.push(me.headClass);
297
+ if (me.repClass) cls.push(me.repClass);
298
+ S.classes = cls.filter(function (v, i) { return v && cls.indexOf(v) === i; });
299
+ if (me.role === 'admin') S.classes = ['35', '36', '37', '38'];
300
+ $('cls').innerHTML = S.classes.map(function (c) { return '<option>' + esc(c) + '</option>'; }).join('');
301
+ S.cls = lsGet('c_cls', '') || S.classes[0] || '';
302
+ if (S.classes.indexOf(S.cls) < 0) S.cls = S.classes[0] || '';
303
+ $('cls').value = S.cls;
304
+ if (!S.classes.length) { $('rollList').innerHTML = '<div class="hint">这个账号还没有班级。</div>'; return; }
305
+ showTab('roll');
306
+ obFlush();
307
+ }).catch(function () {
308
+ var cached = lsGet('c_me', null);
309
+ if (cached && cached.classes) {
310
+ $('off').style.display = 'block';
311
+ S.user = cached; S.classes = cached.classes;
312
+ $('cls').innerHTML = S.classes.map(function (c) { return '<option>' + esc(c) + '</option>'; }).join('');
313
+ S.cls = lsGet('c_cls', '') || S.classes[0]; $('cls').value = S.cls;
314
+ showTab('roll'); obFlush();
315
+ } else {
316
+ $('rollList').innerHTML = '<div class="hint">📵 无法连接服务器,且本机没有缓存账号(首次使用需联网登录一次)</div>';
317
+ }
318
+ });
319
+ }
320
+ boot();
321
+ })();