update
This commit is contained in:
175
frontend/src/components/GuideBoardSection.vue
Normal file
175
frontend/src/components/GuideBoardSection.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchGuideBoard } from '../api.js'
|
||||
import KanbanBoard from './KanbanBoard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
format: { type: String, default: 'Guide' },
|
||||
})
|
||||
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'deleteGuide', 'resetCard'])
|
||||
|
||||
const board = ref(null)
|
||||
let timer = null
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
board.value = await fetchGuideBoard(props.topic, props.format)
|
||||
} catch { /* Board noch leer */ }
|
||||
}
|
||||
function startPoll() { stopPoll(); timer = setInterval(poll, 1200) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
|
||||
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
|
||||
watch(() => board.value?.generating, (g) => { if (g) startPoll(); else stopPoll() })
|
||||
onUnmounted(stopPoll)
|
||||
|
||||
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)
|
||||
|
||||
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
|
||||
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
|
||||
const sel = ref(null)
|
||||
const selCard = ref(null)
|
||||
const confirm = ref(null)
|
||||
|
||||
function stageClick(c) {
|
||||
if (generating.value || !STAGES.includes(c.key)) return
|
||||
confirm.value = null
|
||||
selCard.value = null
|
||||
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
|
||||
}
|
||||
|
||||
function cardClick(k) {
|
||||
if (generating.value || !k.card_id) return
|
||||
confirm.value = null
|
||||
sel.value = null
|
||||
const idx = Math.max(0, STAGES.indexOf(k.column))
|
||||
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
|
||||
}
|
||||
|
||||
function resetCardHere() {
|
||||
const k = selCard.value
|
||||
selCard.value = null
|
||||
confirm.value = null
|
||||
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
|
||||
setTimeout(poll, 400)
|
||||
}
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
function restartHere() {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
emit('startGuide', { format: props.format, abStep: s.idx })
|
||||
startPoll()
|
||||
}
|
||||
function resetHere() {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
emit('resetStage', { format: props.format, abStage: s.idx })
|
||||
setTimeout(poll, 400)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="gb-board">
|
||||
<div class="gb-top">
|
||||
<span class="gb-title">Guide · {{ format }}</span>
|
||||
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</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 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 play" @click="emit('startGuide', { format, abStep: null }); 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: confirm === 'delete' }" @click="arm('delete', () => emit('deleteGuide', board?.guide_id))">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KanbanBoard
|
||||
:columns="columns"
|
||||
:agents="board?.agents || []"
|
||||
:generating="generating"
|
||||
:selectable="!generating"
|
||||
:cardSelectable="!generating"
|
||||
:selectedKey="sel?.key || null"
|
||||
@stageClick="stageClick"
|
||||
@cardClick="cardClick"
|
||||
/>
|
||||
|
||||
<div v-if="selCard && !generating" class="gb-stage-actions">
|
||||
<span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
|
||||
<button class="gb-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? resetCardHere() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : '↻ Karte neu (ab Lernziele)' }}</button>
|
||||
<button class="gb-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
<div v-if="sel && !generating" class="gb-stage-actions">
|
||||
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
|
||||
<button class="gb-act play" @click="restartHere">↻ neu generieren</button>
|
||||
<button class="gb-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : '✕ nur zurücksetzen' }}</button>
|
||||
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
|
||||
<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; }
|
||||
.gb-title { font-size: 0.9rem; font-weight: 700; }
|
||||
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
|
||||
.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: gb-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
.gb-error { color: var(--danger); font-size: 0.82rem; }
|
||||
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
|
||||
.gb-stage-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
.gb-stage-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
.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.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; }
|
||||
.gb-act.ghost { color: var(--text-muted); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user