feat(studio): Einstellungen-Reiter je Fall, KI-Weg wählbar und sichtbar
Das Studio konnte Fälle bisher nur anlegen, nicht bearbeiten. Es gab kein Einstellungs-Panel, alle Felder waren nach dem Anlegen eingefroren. Wer etwas ändern wollte, musste zurück in die klassische Ansicht. Für die geplante Ablösung der klassischen Ansicht ist das der erste Blocker. Neuer Reiter Einstellungen in der Fallnavigation, unter Export. Er führt alle Felder des klassischen Bearbeiten-Modals: Titel, Beschreibung, die drei Quellenschalter, Sichtbarkeit, KI-Verarbeitung, Aktualisierung mit Intervall und Startzeit, Aufbewahrung und die drei E-Mail-Benachrichtigungen. Gespeichert wird nur, was sich geändert hat. Zwei Gründe. Der Server aktualisiert feldweise, unverändert zurückgereichte Werte würden trotzdem geschrieben. Und eine unbeabsichtigte Änderung am Intervall kann einen laufenden Zeitplan verstellen. Die Wahl des KI-Wegs, also Anthropic gegen die EU-Fassung über Bedrock Frankfurt, fehlte im Studio vollständig. Sie steht jetzt im Reiter und im Anlege-Dialog, wortgleich zur klassischen Ansicht. Beim Umstellen erscheint ein Hinweis, dass bereits erzeugte Inhalte auf dem bisherigen Weg entstanden sind und die Änderung ab dem nächsten Lauf greift. Läuft gerade ein Schritt, sagt der Reiter das ebenfalls. Dazu die Kennzeichen EU und Anthropic in Fallliste und Kopfzeile. Sie erscheinen nur, wenn der Fall ausdrücklich auf einen Weg gestellt ist. Ohne Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet. Zwei Feinheiten. Das Intervall wird in der größten glatt aufgehenden Einheit gezeigt, 1440 Minuten also als ein Tag. Und der X-Schalter bleibt bedienbar, wenn der Fall X bereits nutzt, sonst ließe er sich nach dem Wegfall des letzten Zugangs nicht mehr abschalten. Prüfungen ohne Browser und ohne Netzzugriff. 57 Fälle für den Reiter, dazu 29 für die Struktur beider Oberflächen, die prüfen, dass jedes onclick auf eine vorhandene Methode zeigt, dass keine ID doppelt vergeben ist und dass jedes Design-Token ohne Fallback auch definiert ist. Cache-Buster auf 20260802b. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -260,6 +260,7 @@ const Studio = {
|
||||
</label>` : ''}
|
||||
<button class="case-open" onclick="${sel ? `Studio.toggleCaseSel(${i.id}, !Studio.caseSel.has(${i.id}))` : `Studio.selectIncident(${i.id})`}" title="${UI.escape(i.title)}">
|
||||
<span class="case-title">${UI.escape(i.title)}</span>
|
||||
${Studio._aiTag(i, 'case-ai-tag')}
|
||||
<span class="case-count">${i.article_count}</span>
|
||||
</button>
|
||||
${sel ? '' : `<button class="case-menu-btn" onclick="Studio.caseMenu(event, ${i.id})" title="Mehr" aria-label="Mehr">
|
||||
@@ -691,6 +692,10 @@ const Studio = {
|
||||
badge.textContent = research ? 'Analyse' : 'Live';
|
||||
badge.className = 'studio-type-badge' + (research ? ' type-research' : '');
|
||||
badge.style.display = '';
|
||||
// KI-Weg nur zeigen, wenn der Fall ausdruecklich darauf steht. Ohne
|
||||
// Angabe gilt die Vorgabe der Organisation, dann bleibt es unbeschriftet.
|
||||
const aiBadge = document.getElementById('studio-ai-badge');
|
||||
if (aiBadge) aiBadge.innerHTML = this._aiTag(inc, 'incident-ai-badge');
|
||||
document.getElementById('art-summary-title').textContent = research ? 'Recherchebericht' : 'Lagebild';
|
||||
document.getElementById('art-latest-title').textContent = research ? 'Zusammenfassung' : 'Neueste Entwicklungen';
|
||||
},
|
||||
@@ -1333,6 +1338,233 @@ const Studio = {
|
||||
},
|
||||
|
||||
// === Studio-Tabs in der Mitte ===
|
||||
// === Einstellungen des Falls =========================================
|
||||
// Der Reiter ersetzt das Bearbeiten-Modal der klassischen Ansicht. Er
|
||||
// schickt beim Speichern nur, was sich wirklich geaendert hat. Zwei Gruende.
|
||||
// Der Server aktualisiert feldweise, alles Mitgeschickte wuerde sonst
|
||||
// ueberschrieben, auch wenn es nur unveraendert zurueckgereicht wird. Und
|
||||
// eine unbeabsichtigte Aenderung am Intervall kann einen laufenden Zeitplan
|
||||
// verstellen.
|
||||
|
||||
_setEl(id) { return document.getElementById(id); },
|
||||
|
||||
/** Liest das Formular. Der Rueckgabewert ist mit dem Schnappschuss vergleichbar. */
|
||||
_settingsWerte() {
|
||||
const g = (id) => this._setEl(id);
|
||||
const einheit = parseInt(g('set-refresh-unit').value) || 1;
|
||||
const zahl = parseInt(g('set-refresh-value').value) || 15;
|
||||
const auto = g('set-refresh-mode').value === 'auto';
|
||||
const werte = {
|
||||
title: g('set-title').value.trim(),
|
||||
description: g('set-description').value.trim(),
|
||||
international_sources: g('set-international').checked,
|
||||
include_telegram: g('set-telegram').checked,
|
||||
include_x: g('set-x').checked,
|
||||
visibility: g('set-visibility').checked ? 'public' : 'private',
|
||||
ai_backend: g('set-ai-backend').value || '',
|
||||
refresh_mode: g('set-refresh-mode').value,
|
||||
refresh_interval: Math.max(10, Math.min(10080, zahl * einheit)),
|
||||
retention_days: Math.max(0, Math.min(999, parseInt(g('set-retention').value) || 0)),
|
||||
};
|
||||
// Die Startzeit gehoert nur zur automatischen Aktualisierung. Bei
|
||||
// manueller Aktualisierung bleibt der gespeicherte Wert unangetastet,
|
||||
// ausgewertet wird er dort ohnehin nicht.
|
||||
if (auto) werte.refresh_start_time = g('set-refresh-starttime').value || '07:00';
|
||||
return werte;
|
||||
},
|
||||
|
||||
_settingsAbo() {
|
||||
return {
|
||||
notify_email_summary: this._setEl('set-notify-summary').checked,
|
||||
notify_email_new_articles: this._setEl('set-notify-new-articles').checked,
|
||||
notify_email_status_change: this._setEl('set-notify-status-change').checked,
|
||||
};
|
||||
},
|
||||
|
||||
/** Fuellt das Formular aus dem offenen Fall. Dient auch als Verwerfen. */
|
||||
async loadSettings() {
|
||||
const inc = this.incident;
|
||||
if (!inc || !this._setEl('set-title')) return;
|
||||
const g = (id) => this._setEl(id);
|
||||
|
||||
g('set-title').value = inc.title || '';
|
||||
g('set-description').value = inc.description || '';
|
||||
g('set-international').checked = !!inc.international_sources;
|
||||
g('set-telegram').checked = !!inc.include_telegram;
|
||||
g('set-x').checked = !!inc.include_x;
|
||||
g('set-visibility').checked = (inc.visibility || 'public') === 'public';
|
||||
g('set-ai-backend').value = inc.ai_backend || '';
|
||||
g('set-refresh-mode').value = inc.refresh_mode === 'auto' ? 'auto' : 'manual';
|
||||
g('set-refresh-starttime').value = inc.refresh_start_time || '07:00';
|
||||
g('set-retention').value = (inc.retention_days === null || inc.retention_days === undefined)
|
||||
? 0 : inc.retention_days;
|
||||
|
||||
// Intervall in die groesste Einheit zeigen, die glatt aufgeht. 1440
|
||||
// Minuten liest sich als 1 Tag besser denn als 1440 Minuten.
|
||||
const iv = parseInt(inc.refresh_interval) || 15;
|
||||
let einheit = 1;
|
||||
for (const u of [10080, 1440, 60]) { if (iv % u === 0) { einheit = u; break; } }
|
||||
g('set-refresh-unit').value = String(einheit);
|
||||
g('set-refresh-value').value = String(Math.round(iv / einheit));
|
||||
|
||||
this.settingsRefreshToggle();
|
||||
this.settingsIntervalMin();
|
||||
this.settingsVisibilityHint();
|
||||
this._settingsRunningNote();
|
||||
|
||||
// Der Schnappschuss ist die Vergleichsgrundlage fuers Speichern. Er
|
||||
// wird vor dem Abo-Nachladen gesetzt, damit ein langsamer Abruf das
|
||||
// Formular nicht in einem halben Zustand zuruecklaesst.
|
||||
this._settingsAlt = this._settingsWerte();
|
||||
this.settingsAiChanged();
|
||||
this._settingsState('');
|
||||
|
||||
try {
|
||||
const abo = await API.getSubscription(inc.id);
|
||||
g('set-notify-summary').checked = !!(abo && abo.notify_email_summary);
|
||||
g('set-notify-new-articles').checked = !!(abo && abo.notify_email_new_articles);
|
||||
g('set-notify-status-change').checked = !!(abo && abo.notify_email_status_change);
|
||||
} catch (e) {
|
||||
// Ohne Abo-Stand bleiben die Haken aus. Das ist der Serverzustand
|
||||
// fuer einen Fall ohne Abo.
|
||||
g('set-notify-summary').checked = false;
|
||||
g('set-notify-new-articles').checked = false;
|
||||
g('set-notify-status-change').checked = false;
|
||||
}
|
||||
this._settingsAboAlt = this._settingsAbo();
|
||||
|
||||
// Bewusst abgewartet. Erst danach ist das Formular vollstaendig,
|
||||
// vorher stuende der X-Schalter noch auf dem Ausgangswert.
|
||||
await this._settingsXHint();
|
||||
},
|
||||
|
||||
settingsRefreshToggle() {
|
||||
const auto = this._setEl('set-refresh-mode').value === 'auto';
|
||||
this._setEl('set-interval-field').classList.toggle('visible', auto);
|
||||
this._setEl('set-starttime-field').classList.toggle('visible', auto);
|
||||
},
|
||||
|
||||
settingsIntervalMin() {
|
||||
const einheit = parseInt(this._setEl('set-refresh-unit').value);
|
||||
const feld = this._setEl('set-refresh-value');
|
||||
const min = einheit === 1 ? 10 : 1;
|
||||
feld.min = min;
|
||||
if (parseInt(feld.value) < min) feld.value = min;
|
||||
},
|
||||
|
||||
settingsVisibilityHint() {
|
||||
const oeffentlich = this._setEl('set-visibility').checked;
|
||||
this._setEl('set-visibility-text').textContent = oeffentlich
|
||||
? 'Öffentlich, für alle Nutzer sichtbar'
|
||||
: 'Privat, nur für dich sichtbar';
|
||||
},
|
||||
|
||||
/** Warnt, sobald der KI-Weg vom gespeicherten Stand abweicht. */
|
||||
settingsAiChanged() {
|
||||
const w = this._setEl('set-ai-warn');
|
||||
if (!w) return;
|
||||
const jetzt = this._setEl('set-ai-backend').value || '';
|
||||
const alt = (this._settingsAlt && this._settingsAlt.ai_backend) || '';
|
||||
w.hidden = jetzt === alt;
|
||||
},
|
||||
|
||||
_settingsRunningNote() {
|
||||
const n = this._setEl('set-running-note');
|
||||
if (n) n.hidden = !this._runningStage;
|
||||
},
|
||||
|
||||
_settingsState(text, art) {
|
||||
const s = this._setEl('set-state');
|
||||
if (!s) return;
|
||||
s.textContent = text || '';
|
||||
s.className = 'settings-state' + (art ? ' ' + art : '');
|
||||
},
|
||||
|
||||
/** X laesst sich nur einschalten, wenn ein Zugang hinterlegt ist. */
|
||||
async _settingsXHint() {
|
||||
const cb = this._setEl('set-x');
|
||||
const hint = this._setEl('set-x-hint');
|
||||
if (!cb) return;
|
||||
let aktiv = 0;
|
||||
try {
|
||||
const konten = await API.listXAccounts();
|
||||
aktiv = (konten || []).filter(a => a.active).length;
|
||||
} catch (e) {
|
||||
aktiv = 0; // im Zweifel sperren
|
||||
}
|
||||
// Nutzt der Fall X bereits, bleibt der Schalter bedienbar. Sonst koennte
|
||||
// man ihn nach dem Wegfall des letzten Zugangs nicht mehr abschalten.
|
||||
cb.disabled = aktiv === 0 && !cb.checked;
|
||||
if (hint) hint.hidden = aktiv !== 0;
|
||||
},
|
||||
|
||||
async saveSettings() {
|
||||
const inc = this.incident;
|
||||
if (!inc || !this._setEl('set-title')) return;
|
||||
const knopf = this._setEl('set-save');
|
||||
|
||||
const jetzt = this._settingsWerte();
|
||||
if (!jetzt.title) {
|
||||
UI.showToast('Ohne Titel geht es nicht', 'error');
|
||||
this._setEl('set-title').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const alt = this._settingsAlt || {};
|
||||
const patch = {};
|
||||
Object.keys(jetzt).forEach(k => {
|
||||
if (jetzt[k] !== alt[k]) patch[k] = jetzt[k];
|
||||
});
|
||||
|
||||
const abo = this._settingsAbo();
|
||||
const aboAlt = this._settingsAboAlt || {};
|
||||
const aboGeaendert = Object.keys(abo).some(k => abo[k] !== aboAlt[k]);
|
||||
|
||||
if (!Object.keys(patch).length && !aboGeaendert) {
|
||||
this._settingsState('Nichts geändert');
|
||||
return;
|
||||
}
|
||||
|
||||
knopf.disabled = true;
|
||||
this._settingsState('Wird gespeichert …');
|
||||
try {
|
||||
if (Object.keys(patch).length) {
|
||||
const neu = await API.updateIncident(inc.id, patch);
|
||||
// Der Server antwortet mit der angereicherten Lage. Sie ist die
|
||||
// verlaessliche Quelle, das Formular koennte abweichen.
|
||||
this.incident = (neu && neu.id) ? neu : Object.assign({}, inc, patch);
|
||||
const i = this.incidents.findIndex(x => x.id === inc.id);
|
||||
if (i >= 0) this.incidents[i] = Object.assign({}, this.incidents[i], this.incident);
|
||||
}
|
||||
if (aboGeaendert) await API.updateSubscription(inc.id, abo);
|
||||
|
||||
this._settingsAlt = this._settingsWerte();
|
||||
this._settingsAboAlt = abo;
|
||||
this.settingsAiChanged();
|
||||
this.renderHeader(this.incident);
|
||||
this.renderCases();
|
||||
this._settingsState('Gespeichert', 'ok');
|
||||
UI.showToast('Einstellungen gespeichert', 'success');
|
||||
} catch (e) {
|
||||
this._settingsState('Nicht gespeichert', 'fehler');
|
||||
UI.showToast((e && e.message) || 'Einstellungen konnten nicht gespeichert werden', 'error');
|
||||
} finally {
|
||||
knopf.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** Kennzeichen des KI-Wegs. Leer, solange die Org-Vorgabe gilt. */
|
||||
_aiTag(inc, klasse) {
|
||||
const b = inc && inc.ai_backend;
|
||||
if (b === 'bedrock') {
|
||||
return `<span class="${klasse} ai-eu" title="KI-Verarbeitung in der EU (AWS Bedrock, Frankfurt), Recherche über den europäischen Suchindex staan">EU</span>`;
|
||||
}
|
||||
if (b === 'cli') {
|
||||
return `<span class="${klasse} ai-anthropic" title="KI-Verarbeitung über Anthropic mit eingebauter Websuche (heutiger Weg)">Anthropic</span>`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
_tabTitle(key) {
|
||||
const el = document.querySelector(`#studio-menu .studio-menu-item[data-art="${CSS.escape(key)}"] .mi-title`);
|
||||
return el ? el.textContent.trim() : key;
|
||||
@@ -1395,6 +1627,7 @@ const Studio = {
|
||||
}
|
||||
if (key === 'snapshots') this.loadSnapshots();
|
||||
if (key === 'timeline') this.loadTimeline();
|
||||
if (key === 'settings') this.loadSettings();
|
||||
},
|
||||
|
||||
closeTab(key, ev) {
|
||||
@@ -2088,6 +2321,7 @@ const Studio = {
|
||||
include_telegram: document.getElementById('inc-telegram').checked,
|
||||
include_x: document.getElementById('inc-x').checked,
|
||||
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
|
||||
ai_backend: (document.getElementById('inc-ai-backend') || {}).value || '',
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren