Files
ruoyi-vue-pro/tools/education-student-harness/app.js

46 lines
13 KiB
JavaScript

const API_PREFIX = '/app-api';
let accessToken = '';
let tenant = null;
let currentSession = null;
let clientSequence = 0;
let expectedSessionVersion = 0;
let saveState = 'idle';
const $ = (id) => document.getElementById(id);
const tokenFor = () => accessToken;
export function buildRequest(path, options = {}, token = accessToken) {
const headers = { Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) };
return { url: `${API_PREFIX}${path}`, options: { ...options, headers } };
}
export function nextAnswerCommand(session, questionSequence, selectedAnswer, key = crypto.randomUUID()) {
return { sessionId: session.id, questionSequence, selectedAnswer, idempotencyKey: key, clientSequence: (session.acceptedSequence || 0) + 1, expectedSessionVersion: session.sessionVersion ?? session.serverVersion ?? 0 };
}
export function applyAnswerState(session, result) { return { ...session, sessionVersion: result.sessionVersion, serverVersion: result.serverVersion ?? result.sessionVersion, acceptedSequence: Math.max(session.acceptedSequence || 0, result.acceptedSequence || 0) }; }
function setStatus(text, tone = 'neutral') { $('status').textContent = text; $('status').dataset.tone = tone; $('connection-dot').dataset.tone = tone; }
function setSaveState(state, text) { saveState = state; const node = $('save-state'); if (node) { node.textContent = text; node.dataset.state = state; } }
function requestId(response) { const id = response.headers.get('x-request-id') || response.headers.get('x-trace-id'); if (id) $('request-id').textContent = `req ${id}`; }
function query(params = {}) { const value = new URLSearchParams(); Object.entries(params).forEach(([key, item]) => { if (item !== undefined && item !== null && item !== '') value.set(key, item); }); const result = value.toString(); return result ? `?${result}` : ''; }
async function api(path, options = {}) { const request = buildRequest(path, options); const response = await fetch(request.url, request.options); requestId(response); const payload = await response.json().catch(() => ({})); if (!response.ok || (payload.code !== undefined && payload.code !== 0)) { const error = new Error(payload.msg || `Request failed (${response.status})`); error.status = response.status; error.data = payload.data; throw error; } return payload.data; }
function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c])); }
function button(label, handler, className = 'button button-outline') { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.className = className; b.addEventListener('click', handler); return b; }
function renderList(target, list, emptyText, render) { const node = $(target); node.innerHTML = ''; if (!list?.length) { node.innerHTML = `<p class="empty-state">${escapeHtml(emptyText)}</p>`; return; } list.forEach((item) => node.appendChild(render(item))); }
function item(title, detail, action) { const node = document.createElement('article'); node.className = 'list-item'; node.innerHTML = `<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(detail || '')}</span></div>`; if (action) node.append(action); return node; }
function renderContext(data) { tenant = data; $('identity-chip').textContent = `${data.tenantName || data.tenantId} · ${data.displayName || data.userId}`; $('context').innerHTML = `<div><dt>Tenant</dt><dd>${escapeHtml(data.tenantName || data.tenantId)}</dd></div><div><dt>Student</dt><dd>${escapeHtml(data.displayName || data.userId)}</dd></div>`; }
async function resolveTenant() { return api('/education/tenant/resolve'); }
async function connect(event) { event?.preventDefault(); accessToken = $('token').value.trim(); if (!accessToken) { const mobile = $('mobile').value.trim(); const password = $('password').value; if (!mobile || !password) { setStatus('Enter member credentials or a local token', 'bad'); return; } try { setStatus('Logging in…'); const login = await api('/member/auth/login', { method: 'POST', body: JSON.stringify({ mobile, password }) }, ''); accessToken = login?.accessToken || login?.token || ''; } catch (error) { setStatus(error.message, 'bad'); return; } } try { setStatus('Resolving tenant…'); await resolveTenant(); const context = await api('/education/context'); renderContext(context); setStatus('Connected', 'good'); await loadCatalog(); await loadCurrent(); } catch (error) { setStatus(error.message, 'bad'); } }
async function loginWithCredentials() { return null; }
async function logout() { try { if (accessToken) await api('/member/auth/logout', { method: 'POST' }); } catch (_) { /* local memory is still cleared */ } accessToken = ''; tenant = null; currentSession = null; $('identity-chip').textContent = 'Offline'; $('context').innerHTML = '<div><dt>Tenant</dt><dd>Not resolved</dd></div><div><dt>Student</dt><dd>Not authenticated</dd></div>'; renderPractice(); setStatus('Logged out', 'neutral'); }
async function loadCatalog() { try { setStatus('Loading catalog…'); const [collections, subjects] = await Promise.all([api('/education/catalog/question-collections?limit=20'), api('/education/catalog/subjects')]); const select = $('subject-filter'); select.innerHTML = '<option value="">All subjects</option>' + (subjects || []).map((x) => `<option value="${escapeHtml(x.id)}">${escapeHtml(x.name || x.title || x.id)}</option>`).join(''); renderList('collections', collections, 'No permitted collections returned.', collectionCard); setStatus('Catalog ready', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
function collectionCard(collection) { const article = document.createElement('article'); article.className = 'collection-card'; article.innerHTML = `<div class="collection-index">SET</div><h3>${escapeHtml(collection.name || collection.title || collection.id)}</h3><p>${escapeHtml(collection.description || 'A focused set for your next study pass.')}</p><div class="collection-meta"><span>${collection.questionCount ?? '?'} questions</span><span>${escapeHtml(collection.status || 'available')}</span></div>`; article.append(button('Start practice', () => createPractice(collection), 'button button-dark')); return article; }
async function createPractice(collection) { try { setStatus('Creating practice…'); currentSession = await api('/education/practice-session/create', { method: 'POST', body: JSON.stringify({ clientSessionId: crypto.randomUUID(), collectionId: collection.id, questionCount: Math.min(collection.questionCount || 5, 5) }) }); clientSequence = currentSession.acceptedSequence || 0; expectedSessionVersion = currentSession.sessionVersion || 0; renderPractice(); setStatus('Practice active', 'good'); location.hash = 'practice'; } catch (error) { setStatus(error.message, 'bad'); } }
async function loadCurrent() { try { setStatus('Checking your session…'); currentSession = await api('/education/practice-session/current'); if (currentSession) { clientSequence = currentSession.acceptedSequence || 0; expectedSessionVersion = currentSession.sessionVersion || 0; } renderPractice(); setStatus(currentSession ? 'Session recovered' : 'No active session', currentSession ? 'good' : 'neutral'); } catch (error) { setStatus(error.message, 'bad'); } }
function renderPractice() { const host = $('practice'); host.innerHTML = ''; $('state-readout').textContent = currentSession ? `${currentSession.status} · v${currentSession.sessionVersion ?? 0}` : 'No session'; if (!currentSession) { host.innerHTML = '<p class="empty-state">No active session. Start one above.</p>'; return; } const heading = document.createElement('div'); heading.className = 'practice-head'; heading.innerHTML = `<div><span class="session-badge">${escapeHtml(currentSession.status)}</span><strong>${currentSession.questionCount || currentSession.questions?.length || 0} questions</strong></div><span id="save-state" class="save-state" data-state="idle">Ready</span>`; host.append(heading); (currentSession.questions || []).forEach((question) => { const field = document.createElement('fieldset'); field.className = 'question'; field.innerHTML = `<legend><span>Q${question.sequence}</span>${escapeHtml(question.stem || question.questionId)}</legend><div class="options">${(question.options || []).map((option, index) => { const value = String.fromCharCode(65 + index); return `<label class="option"><input type="radio" name="q-${question.sequence}" value="${value}" ${question.selectedAnswer === value ? 'checked' : ''}><span><b>${value}</b>${escapeHtml(option)}</span></label>`; }).join('')}</div>`; field.querySelectorAll('input').forEach((input) => input.addEventListener('change', () => saveAnswer(question, input.value))); host.append(field); }); if (currentSession.status === 'ACTIVE') { const actions = document.createElement('div'); actions.className = 'practice-actions'; actions.append(button('Submit practice', submitPractice, 'button button-dark')); host.append(actions); } }
async function saveAnswer(question, answer) { const command = nextAnswerCommand({ ...currentSession, acceptedSequence: clientSequence, sessionVersion: expectedSessionVersion }, question.sequence, answer, question.pendingKey || crypto.randomUUID()); question.pendingKey = command.idempotencyKey; setSaveState('saving', 'Saving…'); try { const result = await api('/education/practice-session/answer', { method: 'PUT', body: JSON.stringify(command) }); currentSession = applyAnswerState(currentSession, result); clientSequence = currentSession.acceptedSequence; expectedSessionVersion = currentSession.sessionVersion; question.selectedAnswer = answer; setSaveState('saved', 'Saved'); $('state-readout').textContent = `${currentSession.status} · v${expectedSessionVersion}`; } catch (error) { if (error.status === 504 || error.status >= 500) { setSaveState('retrying', 'Retrying…'); try { const result = await api('/education/practice-session/answer', { method: 'PUT', body: JSON.stringify(command) }); currentSession = applyAnswerState(currentSession, result); clientSequence = currentSession.acceptedSequence; expectedSessionVersion = currentSession.sessionVersion; question.selectedAnswer = answer; setSaveState('saved', 'Saved after retry'); return; } catch (_) {} } setSaveState('failed', 'Save failed — retry by changing this answer'); setStatus(error.message, 'bad'); } }
async function submitPractice() { if (!currentSession) return; try { setStatus('Submitting…'); const result = await api('/education/practice-session/submit', { method: 'POST', body: JSON.stringify({ sessionId: currentSession.id, idempotencyKey: crypto.randomUUID(), expectedSessionVersion }) }); currentSession.status = 'SUBMITTED'; currentSession.sessionVersion = result.sessionVersion || expectedSessionVersion + 1; expectedSessionVersion = currentSession.sessionVersion; renderPractice(); setStatus(`Submitted · ${result.score ?? '—'} correct`, 'good'); await loadWrong(); } catch (error) { setStatus(error.message, 'bad'); } }
async function loadWrong() { try { const data = await api('/education/wrong-question/page?pageNo=1&pageSize=20'); renderList('wrong', data?.list, 'No wrong questions yet.', (x) => item(x.questionStem || x.stem || x.questionId || x.id, `${x.errorCount ?? 0} ${x.errorCount === 1 ? 'miss' : 'misses'} · ${x.masterStatus || 'unmastered'}`, x.masterStatus !== 'MASTERED' ? button('Mark mastered', () => masterWrong(x), 'button button-small') : null)); setStatus('Wrong questions loaded', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
async function masterWrong(wrong) { try { await api('/education/wrong-question/master', { method: 'PUT', body: JSON.stringify({ id: wrong.id }) }); await loadWrong(); } catch (error) { setStatus(error.message, 'bad'); } }
async function loadFavorites() { try { const data = await api('/education/favorite/page?pageNo=1&pageSize=20'); renderList('favorites-list', data?.list, 'No favorites yet.', (x) => item(x.questionStem || x.stem || x.targetId, x.targetType || 'QUESTION', button('Remove', () => removeFavorite(x), 'button button-small'))); setStatus('Favorites loaded', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
async function removeFavorite(favorite) { try { await api('/education/favorite/delete', { method: 'DELETE', body: JSON.stringify({ id: favorite.id, targetId: favorite.targetId }) }); await loadFavorites(); } catch (error) { setStatus(error.message, 'bad'); } }
$('auth-form').addEventListener('submit', connect); $('logout').addEventListener('click', logout); $('load-catalog').addEventListener('click', loadCatalog); $('load-current').addEventListener('click', loadCurrent); $('load-wrong').addEventListener('click', loadWrong); $('load-favorites').addEventListener('click', loadFavorites);