init
This commit is contained in:
177
frontend/src/components/Board.vue
Normal file
177
frontend/src/components/Board.vue
Normal 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>
|
||||
Reference in New Issue
Block a user