feat(journal): live updates on new entry via SSE (/api/events) — admin journal auto-refreshes when a student submits
This commit is contained in:
@@ -219,6 +219,29 @@ function goPage(p) {
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
let liveSource = null;
|
||||
let liveReconnectTimer = null;
|
||||
function connectLiveUpdates() {
|
||||
if (liveSource) { liveSource.close(); liveSource = null; }
|
||||
const connect = () => {
|
||||
const t = sessionStorage.getItem('authToken');
|
||||
if (!t) return;
|
||||
liveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
|
||||
liveSource.addEventListener('entries_changed', () => {
|
||||
showToast('Поступила новая запись — список обновлён');
|
||||
page = 1;
|
||||
loadEntries();
|
||||
});
|
||||
liveSource.onerror = () => {
|
||||
if (liveSource) { liveSource.close(); liveSource = null; }
|
||||
if (liveReconnectTimer) clearTimeout(liveReconnectTimer);
|
||||
liveReconnectTimer = setTimeout(connect, 5000);
|
||||
};
|
||||
};
|
||||
connect();
|
||||
window.addEventListener('beforeunload', () => { if (liveSource) liveSource.close(); });
|
||||
}
|
||||
|
||||
async function openEdit(id) {
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
@@ -670,5 +693,6 @@ document.getElementById('linkCopyBtn').addEventListener('click', copyLink);
|
||||
loadGroupOptions();
|
||||
loadStudentOptions();
|
||||
loadEntries();
|
||||
connectLiveUpdates();
|
||||
}
|
||||
})();
|
||||
@@ -63,6 +63,36 @@ function invalidateSettings() { cacheDrop('setting:'); cacheDrop('share:payload:
|
||||
function invalidateStudents() { cacheDrop('students:'); }
|
||||
function invalidateGroups() { cacheDrop('groups:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||||
function invalidateEntries() { cacheDrop('entries:'); cacheDrop('students:'); cacheDrop('share:payload:'); }
|
||||
|
||||
const sseClients = new Set();
|
||||
function broadcastEntryChanged() {
|
||||
const frame = `event: entries_changed\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`;
|
||||
for (const client of sseClients) {
|
||||
try { client.write(frame); } catch (e) { sseClients.delete(client); }
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/events', async (req, res) => {
|
||||
try {
|
||||
const token = req.headers['x-auth-token'] || req.query.token;
|
||||
const user = await loadUserByToken(token);
|
||||
if (!user || !user.is_active) return res.status(401).end();
|
||||
} catch (e) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
res.write(':ok\n\n');
|
||||
sseClients.add(res);
|
||||
const ping = setInterval(() => {
|
||||
try { res.write(':ping\n\n'); } catch (e) { clearInterval(ping); sseClients.delete(res); }
|
||||
}, 25000);
|
||||
req.on('close', () => { clearInterval(ping); sseClients.delete(res); });
|
||||
});
|
||||
function invalidateShare() { cacheDrop('share:payload:'); }
|
||||
function invalidateStats() { cacheDrop('stats:'); cacheDrop('dashboard:'); cacheDrop('system-info'); }
|
||||
function invalidateAll() { cacheStore.clear(); }
|
||||
@@ -3325,6 +3355,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
|
||||
if (entryAutoChecker) entryAutoChecker.notify();
|
||||
invalidateEntries();
|
||||
invalidateStats();
|
||||
broadcastEntryChanged();
|
||||
res.status(201).json({ ...rows[0], files: projectFiles.length, photos: photos.length });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user