This commit is contained in:
team3
2026-07-10 15:43:11 +02:00
commit 0a41166cca
72 changed files with 7772 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
<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'])
// 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: '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)]] }))
}
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
await api.start(props.topic)
emit('reload')
}
async function menueEntfernen() {
const m = menue.value
menue.value = null
if (!m) return
await api.ebeneEntfernen(props.topic, m.key)
emit('reload')
}
async function autoSetzen(key, ev) {
await api.setAuto(props.topic, key, ev.target.checked)
emit('reload')
}
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>