Files
creator/frontend/src/components/GuideBoardSection.vue
2026-07-06 17:32:52 +02:00

198 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard, fetchRuns, repairGuideBoard, runQa } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
import KanbanBoard from './KanbanBoard.vue'
import ProgressBar from './ProgressBar.vue'
const props = defineProps({
topic: { type: String, required: true },
format: { type: String, default: 'Guide' },
refresh: { type: Number, default: 0 }, // Eltern-Signal: QA/Repair schrieben einen Guide-Report
auto: { type: Boolean, default: true },
})
const emit = defineEmits(['cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
const board = ref(null)
const pollError = ref(null)
async function poll() {
try {
board.value = await fetchGuideBoard(props.topic, props.format)
pollError.value = null
} catch (e) {
if (e.status === 404) board.value = null
else pollError.value = e.message
}
}
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
watch(() => props.refresh, () => poll())
watch(() => board.value?.generating, (g) => { if (g) startPoll() })
const generating = computed(() => !!board.value?.generating)
const columns = computed(() => board.value?.columns || [])
const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
const progressValue = computed(() => (total.value ? done.value / total.value : 0))
// Laufzeit + Tokens aus /api/runs (deckt auch Guide-Läufe ab — beide setzen run_id)
const run = ref(null)
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
const { runs } = await fetchRuns(props.topic, 1)
run.value = runs[0] || null
} catch { run.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => generating.value, 5000)
const runLaufzeit = computed(() => {
if (!run.value?.start) return null
const start = Date.parse(run.value.start)
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
return fmtRuntime((ende - start) / 1000)
})
const runTokens = computed(() => {
const t = run.value?.tokens
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
})
watch(() => generating.value, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } }
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
const { isArmed, armOrRun } = useConfirm() // 2-Klick-Bestätigung
const qaBusy = ref(false)
async function runQaClick() {
if (qaBusy.value) return
qaBusy.value = true
try { await runQa(props.topic) }
finally { qaBusy.value = false; poll() }
}
const repairBusy = ref(false)
const repairInfo = ref('')
async function repairClick() {
repairBusy.value = true
repairInfo.value = ''
try {
const r = await repairGuideBoard(props.topic, props.format)
repairInfo.value = (r.betroffen || []).length
? `${r.betroffen.length} Abschnitt(e) → Prüfen`
: 'keine Befunde im letzten QA-Report'
if ((r.betroffen || []).length) startPoll()
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {
repairBusy.value = false
}
}
</script>
<template>
<section class="gb-board">
<div class="gb-top">
<span class="gb-title">Guide · {{ format }}</span>
<span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 10 ? 'ok' : 'bad'"
title="Guide-QA">QA {{ Math.round(board.qa_guide * 10) }} %</span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="auto" @change="emit('setAuto', $event.target.checked)"> Auto
</label>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
<span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
<div v-if="pollError" class="gb-error">Board nicht erreichbar: {{ pollError }}</div>
<div class="gb-actions">
<template v-if="generating">
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
</template>
<template v-else>
<button class="gb-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="total && board?.qa_guide != null && board.qa_guide < 10" class="gb-act"
:disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span>
<button class="gb-act play" @click="emit('startGuide', { format }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gb-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button>
</template>
</div>
</div>
<ProgressBar v-if="total" :value="progressValue"
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
<KanbanBoard :columns="columns" :agents="board?.agents || []" :generating="generating" />
<div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
</section>
</template>
<style scoped>
.gb-board { padding: 0.85rem 0 0; }
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; flex-wrap: wrap; }
.gb-title { font-size: 0.9rem; font-weight: 700; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
.gb-qa {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
}
.gb-qa.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.gb-qa.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.auto-box {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.auto-box input { cursor: pointer; margin: 0; }
.gb-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
}
.gb-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse-soft 1.2s ease-in-out infinite;
}
.gb-error { color: var(--danger); font-size: 0.82rem; }
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; flex-wrap: wrap; }
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
.gb-act {
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
font-weight: 600;
}
.gb-act:hover { border-color: var(--accent); }
.gb-act:disabled { opacity: 0.55; cursor: default; }
.gb-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gb-act.play:hover { background: var(--accent-hover); }
.gb-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gb-act.danger.armed { background: var(--danger); color: #fff; }
</style>