183 lines
7.3 KiB
Vue
183 lines
7.3 KiB
Vue
<script setup>
|
|
// Ebenen-Kanban: 5 Spalten = Pipeline links→rechts, jede Spalte selbst steuerbar.
|
|
// Befunde + laufende Agenten als Leiste ÜBER dem Board.
|
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
import { api } from '../api.js'
|
|
|
|
const props = defineProps({ state: Object, topic: String })
|
|
const emit = defineEmits(['reload', 'fehler'])
|
|
|
|
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
|
|
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
|
|
const jetzt = ref(Date.now())
|
|
const empfangen = ref(Date.now())
|
|
const ebenenStart = ref({}) // Ebene → lokaler Start-Anker (epoch ms), monoton
|
|
watch(() => props.state, () => {
|
|
empfangen.value = Date.now()
|
|
const neu = {}
|
|
for (const e of props.state?.ebenen || []) {
|
|
if (!e.laeuft) continue
|
|
// Server-Dauer ist eine Untergrenze (Event-zu-Event); frühester Anker gewinnt,
|
|
// damit die Anzeige beim nächsten State-Load nicht auf 0 zurückspringt
|
|
const ausServer = Date.now() - (e.dauer_s || 0) * 1000
|
|
const bisher = ebenenStart.value[e.name]
|
|
neu[e.name] = bisher ? Math.min(bisher, ausServer) : ausServer
|
|
}
|
|
ebenenStart.value = neu
|
|
})
|
|
const ticker = setInterval(() => { jetzt.value = Date.now() }, 1000)
|
|
onUnmounted(() => clearInterval(ticker))
|
|
const extraSek = computed(() => Math.max(0, (jetzt.value - empfangen.value) / 1000))
|
|
|
|
function ebenenDauer(key) {
|
|
const i = info(key)
|
|
if (i.laeuft && ebenenStart.value[key])
|
|
return Math.max(i.dauer_s || 0, Math.round((jetzt.value - ebenenStart.value[key]) / 1000))
|
|
return i.dauer_s || 0
|
|
}
|
|
|
|
const EBENEN = [
|
|
{ key: 'korpus', titel: 'Quellen' },
|
|
{ key: 'inventar', titel: 'Atome' },
|
|
{ key: 'artefakte', titel: 'Artefakte' },
|
|
{ key: 'struktur', titel: 'Bausteine' },
|
|
{ key: 'diagramme', titel: 'Diagramme' },
|
|
{ key: 'guide', titel: 'Guide' },
|
|
]
|
|
|
|
const statusFarbe = (s) => ({
|
|
bestaetigt: 'gruen', verifiziert: 'gruen', fertig: 'gruen', done: 'gruen', aktiv: 'gruen',
|
|
extrahiert: 'blau', atome: 'blau', kandidat: 'gelb', abgedeckt: 'gelb',
|
|
repair: 'gelb', ohne_anker: 'rot', verworfen: 'rot',
|
|
}[s] || '')
|
|
|
|
const info = (key) => props.state?.ebenen?.find((e) => e.name === key) || {}
|
|
|
|
const aktiveAtome = computed(() =>
|
|
(props.state?.atome || []).filter((a) => !['gemerged', 'verworfen'].includes(a.status)))
|
|
|
|
function karten(key) {
|
|
const s = props.state
|
|
if (!s) return []
|
|
if (key === 'korpus') {
|
|
return [
|
|
...s.quellen.map((q) => ({ id: `q${q.id}`, text: q.titel,
|
|
badges: [[q.status, statusFarbe(q.status)], [q.rolle, q.rolle === 'aufgaben' ? 'gelb' : 'blau']] })),
|
|
...s.soll.map((p) => ({ id: `s${p.id}`, text: p.punkt,
|
|
badges: [[p.status, statusFarbe(p.status)]] })),
|
|
]
|
|
}
|
|
if (key === 'inventar') {
|
|
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
|
|
badges: [[a.status, statusFarbe(a.status)], [`${a.typ} · ${a.level}`, '']] }))
|
|
}
|
|
if (key === 'artefakte') {
|
|
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
|
|
badges: [[`${a.artefakte.verifiziert}/${a.artefakte.gesamt} verifiziert`,
|
|
a.artefakte.verifiziert > 0 ? 'gruen' : '']] }))
|
|
}
|
|
if (key === 'struktur') {
|
|
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
|
badges: [[b.status, statusFarbe(b.status)]] }))
|
|
}
|
|
if (key === 'diagramme') {
|
|
return (s.diagramme || []).map((d) => ({ id: d.id, text: `${d.ord + 1}. ${d.titel}`,
|
|
badges: [[d.status, statusFarbe(d.status)], [`${d.typ} · ${d.quelle}`, 'blau']] }))
|
|
}
|
|
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
|
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
|
|
}
|
|
|
|
function generierenErlaubt(idx) {
|
|
if (!props.state || props.state.run?.status === 'running') return false
|
|
const e = info(EBENEN[idx].key)
|
|
if (e.fertig) return false
|
|
return EBENEN.slice(0, idx).every((v) => info(v.key).fertig)
|
|
}
|
|
|
|
const menue = ref(null) // Spalten-Kontextmenü: { x, y, key, titel, idx }
|
|
|
|
function menueOeffnen(ev, e, idx) {
|
|
menue.value = { x: ev.clientX, y: ev.clientY, key: e.key, titel: e.titel, idx }
|
|
}
|
|
|
|
async function menueGenerieren() {
|
|
const m = menue.value
|
|
menue.value = null
|
|
if (!m || !generierenErlaubt(m.idx)) return
|
|
try { await api.start(props.topic); emit('reload') }
|
|
catch (e) { emit('fehler', String(e)) } // sonst schlägt der Start still fehl
|
|
}
|
|
|
|
async function menueEntfernen() {
|
|
const m = menue.value
|
|
menue.value = null
|
|
if (!m) return
|
|
try { await api.ebeneEntfernen(props.topic, m.key); emit('reload') }
|
|
catch (e) { emit('fehler', String(e)) }
|
|
}
|
|
|
|
async function autoSetzen(key, ev) {
|
|
try { await api.setAuto(props.topic, key, ev.target.checked); emit('reload') }
|
|
catch (e) { emit('fehler', String(e)) }
|
|
}
|
|
|
|
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
|
: s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`)
|
|
const mmss = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
|
|
const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : n)
|
|
</script>
|
|
|
|
<template>
|
|
<template v-if="state">
|
|
<div v-if="state.agenten.length" class="leiste">
|
|
<strong style="font-size: 12px; color: var(--dim); white-space: nowrap">
|
|
Agenten ({{ state.agenten.length }})
|
|
</strong>
|
|
<span v-for="a in state.agenten" :key="a.key" class="badge blau"
|
|
:title="a.key">{{ mmss(a.laufzeit + extraSek) }}</span>
|
|
</div>
|
|
<div class="board">
|
|
<div v-for="(e, idx) in EBENEN" :key="e.key" class="spalte"
|
|
@contextmenu.prevent="menueOeffnen($event, e, idx)">
|
|
<div class="spaltekopf">
|
|
<div class="zeile">
|
|
<span class="titel">{{ e.titel }}
|
|
<span class="badge">{{ karten(e.key).length }}</span></span>
|
|
<span class="badge" :class="info(e.key).laeuft ? 'blau' : info(e.key).fertig ? 'gruen' : ''">
|
|
{{ info(e.key).laeuft ? 'läuft' : info(e.key).fertig ? 'fertig' : 'offen' }}
|
|
</span>
|
|
</div>
|
|
<div class="zeile">
|
|
<span class="badge">{{ k(info(e.key).tokens || 0) }} Tok</span>
|
|
<span class="badge">{{ dauer(ebenenDauer(e.key)) }}</span>
|
|
<label><input type="checkbox" :checked="info(e.key).auto"
|
|
@change="autoSetzen(e.key, $event)" /> Auto</label>
|
|
</div>
|
|
</div>
|
|
<div class="liste">
|
|
<div v-for="kt in karten(e.key)" :key="kt.id" class="karte">
|
|
{{ kt.text }}
|
|
<div class="meta">
|
|
<span v-for="([txt, farbe], i) in kt.badges" :key="i"
|
|
class="badge" :class="farbe">{{ txt }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
|
|
@click="menue = null" @contextmenu.prevent="menue = null">
|
|
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
|
|
<button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren">
|
|
Generieren
|
|
</button>
|
|
<button class="rot" @click.stop="menueEntfernen">
|
|
„{{ menue.titel }}" + folgende Ebenen entfernen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div v-else class="seite">Kein Thema gewählt — links anlegen.</div>
|
|
</template>
|