feat: Tutorial-Fortschritt serverseitig persistieren (Resume/Restart)

- Neuer Router /api/tutorial mit GET/PUT/DELETE für Fortschritt pro User
- DB-Migration: tutorial_step + tutorial_completed in users-Tabelle
- Resume-Dialog bei abgebrochenem Tutorial (Fortsetzen/Neu starten)
- Chat-Hinweis passt sich dem Tutorial-Status dynamisch an
- API-Methoden: getTutorialState, saveTutorialState, resetTutorialState

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-03-17 22:51:06 +01:00
Ursprung 4b9ed6439a
Commit 5e194d43e0
6 geänderte Dateien mit 202 neuen und 19 gelöschten Zeilen

Datei anzeigen

@@ -215,6 +215,19 @@ const API = {
},
// Export
// Tutorial-Fortschritt
getTutorialState() {
return this._request('GET', '/tutorial/state');
},
saveTutorialState(data) {
return this._request('PUT', '/tutorial/state', data);
},
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportIncident(id, format, scope) {
const token = localStorage.getItem('osint_token');
return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, {

Datei anzeigen

@@ -6,6 +6,7 @@ const Chat = {
_isOpen: false,
_isLoading: false,
_hasGreeted: false,
_tutorialHintDismissed: false,
_isFullscreen: false,
init() {
@@ -64,10 +65,13 @@ const Chat = {
if (!this._hasGreeted) {
this._hasGreeted = true;
this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.');
// Tutorial-Hinweis beim ersten Chat-Oeffnen der Session
if (typeof Tutorial !== 'undefined' && !sessionStorage.getItem('osint_tutorial_hint_dismissed')) {
this._showTutorialHint();
}
}
// Tutorial-Hinweis bei jedem Oeffnen aktualisieren (wenn nicht dismissed)
if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) {
var oldHint = document.getElementById('chat-tutorial-hint');
if (oldHint) oldHint.remove();
this._showTutorialHint();
}
// Focus auf Input
@@ -288,29 +292,57 @@ const Chat = {
}
},
_showTutorialHint() {
async _showTutorialHint() {
var container = document.getElementById('chat-messages');
if (!container) return;
// API-State laden (Fallback: Standard-Hint)
var state = null;
try { state = await API.getTutorialState(); } catch(e) {}
var hint = document.createElement('div');
hint.className = 'chat-tutorial-hint';
hint.id = 'chat-tutorial-hint';
var textDiv = document.createElement('div');
textDiv.className = 'chat-tutorial-hint-text';
textDiv.innerHTML = '<strong>Tipp:</strong> Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt f\u00fcr Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.';
textDiv.style.cursor = 'pointer';
textDiv.addEventListener('click', function() {
Chat.close();
sessionStorage.setItem('osint_tutorial_hint_dismissed', '1');
if (typeof Tutorial !== 'undefined') Tutorial.start();
});
if (state && !state.completed && state.current_step !== null && state.current_step > 0) {
// Mittendrin abgebrochen
var totalSteps = (typeof Tutorial !== 'undefined') ? Tutorial._steps.length : 32;
textDiv.innerHTML = '<strong>Tipp:</strong> Sie haben den Rundgang bei Schritt ' + (state.current_step + 1) + '/' + totalSteps + ' unterbrochen. Klicken Sie hier, um fortzusetzen.';
textDiv.addEventListener('click', function() {
Chat.close();
Chat._tutorialHintDismissed = true;
if (typeof Tutorial !== 'undefined') Tutorial.start();
});
} else if (state && state.completed) {
// Bereits abgeschlossen
textDiv.innerHTML = '<strong>Tipp:</strong> Sie haben den Rundgang bereits abgeschlossen. <span style="text-decoration:underline;">Erneut starten?</span>';
textDiv.addEventListener('click', async function() {
Chat.close();
Chat._tutorialHintDismissed = true;
try { await API.resetTutorialState(); } catch(e) {}
if (typeof Tutorial !== 'undefined') Tutorial.start(true);
});
} else {
// Nie gestartet
textDiv.innerHTML = '<strong>Tipp:</strong> Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt für Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.';
textDiv.addEventListener('click', function() {
Chat.close();
Chat._tutorialHintDismissed = true;
if (typeof Tutorial !== 'undefined') Tutorial.start();
});
}
var closeBtn = document.createElement('button');
closeBtn.className = 'chat-tutorial-hint-close';
closeBtn.title = 'Schlie\u00dfen';
closeBtn.title = 'Schließen';
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', function(e) {
e.stopPropagation();
hint.remove();
sessionStorage.setItem('osint_tutorial_hint_dismissed', '1');
Chat._tutorialHintDismissed = true;
});
hint.appendChild(textDiv);
hint.appendChild(closeBtn);

Datei anzeigen

@@ -12,6 +12,7 @@ const Tutorial = {
_resizeHandler: null,
_demoRunning: false,
_lastExitedStep: -1,
_highestStep: -1,
_stepTimers: [], // setTimeout-IDs fuer den aktuellen Step
_savedState: null, // Dashboard-Zustand vor dem Tutorial
@@ -1152,14 +1153,52 @@ const Tutorial = {
// -----------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------
start() {
async start(forceRestart) {
if (this._isActive) return;
this._isActive = true;
this._currentStep = -1;
// Chat schließen falls offen
// Chat schliessen falls offen
if (typeof Chat !== 'undefined' && Chat._isOpen) Chat.close();
// Server-State laden (Fallback: direkt starten)
var state = null;
try { state = await API.getTutorialState(); } catch(e) {}
// Resume-Dialog wenn mittendrin abgebrochen
if (!forceRestart && state && !state.completed && state.current_step !== null && state.current_step > 0) {
this._showResumeDialog(state.current_step);
return;
}
this._startInternal(forceRestart ? 0 : null);
},
_showResumeDialog(step) {
var self = this;
var overlay = document.createElement('div');
overlay.className = 'tutorial-resume-overlay';
overlay.innerHTML = '<div class=tutorial-resume-dialog>'
+ '<p>Sie haben den Rundgang bei <strong>Schritt ' + (step + 1) + '/' + this._steps.length + '</strong> unterbrochen.</p>'
+ '<div class=tutorial-resume-actions>'
+ '<button class=tutorial-btn tutorial-btn-next id=tutorial-resume-btn>Fortsetzen</button>'
+ '<button class=tutorial-btn tutorial-btn-secondary id=tutorial-restart-btn>Neu starten</button>'
+ '</div></div>';
document.body.appendChild(overlay);
document.getElementById('tutorial-resume-btn').addEventListener('click', function() {
overlay.remove();
self._startInternal(step);
});
document.getElementById('tutorial-restart-btn').addEventListener('click', async function() {
overlay.remove();
try { await API.resetTutorialState(); } catch(e) {}
self._startInternal(0);
});
},
_startInternal(resumeStep) {
this._isActive = true;
this._highestStep = -1;
this._currentStep = -1;
// Overlay einblenden + Klicks blockieren
this._els.overlay.classList.add('active');
document.body.classList.add('tutorial-active');
@@ -1172,7 +1211,11 @@ const Tutorial = {
this._resizeHandler = this._onResize.bind(this);
window.addEventListener('resize', this._resizeHandler);
this.next();
if (resumeStep && resumeStep > 0) {
this.goToStep(resumeStep);
} else {
this.next();
}
},
stop() {
@@ -1230,7 +1273,14 @@ const Tutorial = {
this._resizeHandler = null;
}
this._markSeen();
// Fortschritt serverseitig speichern
if (this._lastExitedStep >= 0 && this._lastExitedStep < this._steps.length - 1) {
// Mittendrin abgebrochen — Schritt speichern
API.saveTutorialState({ current_step: this._lastExitedStep }).catch(function() {});
} else {
// Komplett durchlaufen oder letzter Schritt
this._markSeen();
}
},
// -----------------------------------------------------------------------
@@ -1258,6 +1308,7 @@ const Tutorial = {
if (this._currentStep >= 0) this._exitStep(this._currentStep);
this._currentStep = index;
if (index > this._highestStep) this._highestStep = index;
this._enterStep(index);
},
@@ -2231,6 +2282,7 @@ const Tutorial = {
// -----------------------------------------------------------------------
_markSeen() {
try { localStorage.setItem('osint_tutorial_seen', '1'); } catch(e) {}
API.saveTutorialState({ completed: true, current_step: null }).catch(function() {});
},
_hasSeen() {