init
This commit is contained in:
35
frontend/src/components/ArchitekturTab.vue
Normal file
35
frontend/src/components/ArchitekturTab.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import EinheitDetail from './EinheitDetail.vue'
|
||||
|
||||
defineProps({ projekt: { type: Object, required: true } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-for="k in projekt.architektur" :key="k.name" class="komponente">
|
||||
<h3>{{ k.name }}</h3>
|
||||
<p class="besch">{{ k.beschreibung }}</p>
|
||||
<p v-if="k.beziehungen.length" class="beziehungen">
|
||||
<template v-for="(b, i) in k.beziehungen" :key="i">
|
||||
<span v-if="i"> · </span>{{ b.typ }} → {{ b.ziel }}
|
||||
</template>
|
||||
</p>
|
||||
<details>
|
||||
<summary>{{ k.einheiten.length }} Einheiten</summary>
|
||||
<EinheitDetail v-for="eid in k.einheiten" :key="eid"
|
||||
:projekt-name="projekt.name" :id="eid" />
|
||||
</details>
|
||||
</section>
|
||||
<p v-if="!projekt.architektur.length" class="leer">Keine Architektur-Sicht vorhanden.</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.komponente {
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 10px;
|
||||
padding: 0.9rem 1.2rem; margin-bottom: 1rem;
|
||||
}
|
||||
.komponente h3 { margin: 0; font-size: 1.02rem; }
|
||||
.besch { color: var(--dezent); font-size: 0.9rem; margin: 0.2rem 0; }
|
||||
.beziehungen { color: var(--akzent); font-size: 0.85rem; margin: 0.2rem 0 0.5rem; }
|
||||
summary { font-size: 0.9rem; color: var(--dezent); padding: 0.3rem 0; }
|
||||
.leer { color: var(--dezent); }
|
||||
</style>
|
||||
35
frontend/src/components/DokuTab.vue
Normal file
35
frontend/src/components/DokuTab.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import FeatureBaum from './FeatureBaum.vue'
|
||||
import ArchitekturTab from './ArchitekturTab.vue'
|
||||
import FlowsTab from './FlowsTab.vue'
|
||||
|
||||
const props = defineProps({ projekt: { type: Object, required: true } })
|
||||
|
||||
// Lesemodus: Kern → Architektur → Bereiche in Abhängigkeitsordnung → Flows.
|
||||
// Reine Sicht auf vorhandene Daten — keine eigenen Inhalte, kann nie veralten.
|
||||
const bereicheGeordnet = computed(() => {
|
||||
const nachName = Object.fromEntries(props.projekt.bereiche.map((b) => [b.name, b]))
|
||||
return props.projekt.doku_ordnung.map((n) => nachName[n]).filter(Boolean)
|
||||
})
|
||||
const kernHtml = computed(() => marked.parse(props.projekt.kern.text || ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="doku">
|
||||
<h2>1. Kern</h2>
|
||||
<div v-html="kernHtml"></div>
|
||||
<h2>2. Architektur</h2>
|
||||
<ArchitekturTab :projekt="projekt" />
|
||||
<h2>3. Funktionsbereiche (in Einlese-Reihenfolge)</h2>
|
||||
<FeatureBaum :projekt="{ ...projekt, bereiche: bereicheGeordnet }" />
|
||||
<h2>4. Abläufe</h2>
|
||||
<FlowsTab :projekt="projekt" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doku h2 { font-size: 1.05rem; margin: 1.4rem 0 0.6rem; color: var(--dezent); }
|
||||
.doku h2:first-child { margin-top: 0; }
|
||||
</style>
|
||||
72
frontend/src/components/EinheitDetail.vue
Normal file
72
frontend/src/components/EinheitDetail.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { fetchEinheit } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
projektName: { type: String, required: true },
|
||||
id: { type: String, required: true },
|
||||
kurz: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const detail = ref(null)
|
||||
const fehler = ref('')
|
||||
|
||||
async function laden() {
|
||||
if (detail.value || fehler.value) return // lazy: einmal beim ersten Aufklappen
|
||||
try {
|
||||
detail.value = await fetchEinheit(props.projektName, props.id)
|
||||
} catch (e) {
|
||||
fehler.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="einheit" @toggle="(e) => e.target.open && laden()">
|
||||
<summary>
|
||||
<code>{{ id }}</code>
|
||||
<span v-if="detail?.veraltet" class="badge gelb">veraltet</span>
|
||||
</summary>
|
||||
<div class="inhalt">
|
||||
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||
<template v-else-if="detail">
|
||||
<p class="besch"><em>{{ detail.beschreibung }}</em></p>
|
||||
<dl class="io">
|
||||
<dt>Input</dt><dd>{{ detail.input || '—' }}</dd>
|
||||
<dt>Output</dt><dd>{{ detail.output || '—' }}</dd>
|
||||
</dl>
|
||||
<ul v-if="detail.entscheidungen.length" class="entscheidungen">
|
||||
<li v-for="(e, i) in detail.entscheidungen" :key="i">{{ e }}</li>
|
||||
</ul>
|
||||
<p v-if="detail.kanten.length" class="kanten">
|
||||
<template v-for="(k, i) in detail.kanten" :key="i">
|
||||
<span v-if="i"> · </span>{{ k.typ }} → <code>{{ k.ziel }}</code>
|
||||
</template>
|
||||
</p>
|
||||
<pre v-if="detail.code">{{ detail.code }}</pre>
|
||||
<p v-else class="kein-code">Anker nicht im aktuellen Code gefunden.</p>
|
||||
</template>
|
||||
<p v-else class="laden">lädt…</p>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.einheit {
|
||||
margin: 0.3rem 0 0.5rem 1.1rem; border: 1px solid var(--rand); border-radius: 8px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.einheit > summary { padding: 0.4rem 0.6rem; font-size: 0.82rem; display: flex; gap: 0.5rem; }
|
||||
.inhalt { padding: 0.1rem 0.8rem 0.7rem; font-size: 0.9rem; }
|
||||
.besch { margin: 0.2rem 0; }
|
||||
.io { display: grid; grid-template-columns: max-content 1fr; gap: 0.1rem 0.7rem;
|
||||
color: var(--dezent); font-size: 0.85rem; margin: 0.3rem 0; }
|
||||
.io dt { font-weight: 600; }
|
||||
.io dd { margin: 0; }
|
||||
.entscheidungen { margin: 0.3rem 0; padding-left: 1.2rem; }
|
||||
.entscheidungen li { margin: 0.15rem 0; }
|
||||
.kanten { color: var(--dezent); font-size: 0.82rem; }
|
||||
.kein-code { color: var(--gelb); font-size: 0.85rem; }
|
||||
.fehler { color: var(--rot); }
|
||||
.laden { color: var(--dezent); }
|
||||
</style>
|
||||
60
frontend/src/components/FeatureBaum.vue
Normal file
60
frontend/src/components/FeatureBaum.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
// Geschäftsführungs-Sicht: WAS das Projekt kann — keine Einheiten, kein Code.
|
||||
// EIN durchgehendes Karten-Raster über die volle Breite; der Bereich steht als
|
||||
// Label auf der Karte. Klick vergrößert die Karte und zeigt alle Fähigkeiten.
|
||||
const props = defineProps({ projekt: { type: Object, required: true } })
|
||||
|
||||
const karten = computed(() =>
|
||||
props.projekt.bereiche.flatMap((b) =>
|
||||
b.features.map((f) => ({ ...f, bereich: b.name, schluessel: b.name + f.titel }))))
|
||||
|
||||
const offen = ref(new Set())
|
||||
function toggle(schluessel) {
|
||||
offen.value.has(schluessel) ? offen.value.delete(schluessel) : offen.value.add(schluessel)
|
||||
offen.value = new Set(offen.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="raster">
|
||||
<div v-for="f in karten" :key="f.schluessel" class="karte feature"
|
||||
:class="{ offen: offen.has(f.schluessel) }" @click="toggle(f.schluessel)">
|
||||
<span class="bereich">{{ f.bereich }}</span>
|
||||
<div class="kopf">
|
||||
<strong>{{ f.titel }}</strong>
|
||||
</div>
|
||||
<p class="besch">{{ f.beschreibung }}</p>
|
||||
<ul v-if="offen.has(f.schluessel)" class="faehigkeiten">
|
||||
<li v-for="tf in f.teilfeatures" :key="tf.titel">{{ tf.titel }}</li>
|
||||
</ul>
|
||||
<span v-else class="mehr">{{ f.teilfeatures.length }} Fähigkeiten ▸</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.raster {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
.feature { cursor: pointer; transition: border-color 0.1s; }
|
||||
.feature:hover { border-color: var(--akzent); }
|
||||
.feature.offen {
|
||||
grid-column: span 2;
|
||||
border-color: var(--akzent);
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.bereich {
|
||||
display: block; font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--dezent); margin-bottom: 0.25rem;
|
||||
}
|
||||
.kopf { display: flex; align-items: baseline; gap: 0.6rem; }
|
||||
.besch { color: var(--dezent); font-size: 0.9rem; margin: 0.3rem 0 0; }
|
||||
.faehigkeiten { margin: 0.6rem 0 0; padding-left: 1.3rem; }
|
||||
.faehigkeiten li { margin: 0.25rem 0; }
|
||||
.mehr { display: block; margin-top: 0.5rem; color: var(--akzent); font-size: 0.82rem; }
|
||||
</style>
|
||||
33
frontend/src/components/FlowsTab.vue
Normal file
33
frontend/src/components/FlowsTab.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import EinheitDetail from './EinheitDetail.vue'
|
||||
|
||||
defineProps({ projekt: { type: Object, required: true } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-for="f in projekt.flows" :key="f.titel" class="flow">
|
||||
<h3>{{ f.titel }}</h3>
|
||||
<ol>
|
||||
<li v-for="(s, i) in f.schritte" :key="i">
|
||||
{{ s.text }}
|
||||
<details v-if="s.einheiten.length" class="refs">
|
||||
<summary><code v-for="(eid, j) in s.einheiten" :key="eid">{{ j ? ', ' : '' }}{{ eid }}</code></summary>
|
||||
<EinheitDetail v-for="eid in s.einheiten" :key="eid"
|
||||
:projekt-name="projekt.name" :id="eid" />
|
||||
</details>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<p v-if="!projekt.flows.length" class="leer">Keine Flow-Sicht vorhanden.</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flow {
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 10px;
|
||||
padding: 0.9rem 1.2rem; margin-bottom: 1rem;
|
||||
}
|
||||
.flow h3 { margin: 0 0 0.4rem; font-size: 1.02rem; }
|
||||
.flow li { margin: 0.3rem 0; }
|
||||
.refs > summary { font-size: 0.8rem; color: var(--dezent); padding: 0.15rem 0; }
|
||||
.leer { color: var(--dezent); }
|
||||
</style>
|
||||
82
frontend/src/components/ProjektAnsicht.vue
Normal file
82
frontend/src/components/ProjektAnsicht.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { fetchProjekt, fetchScanStatus } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
import FeatureBaum from './FeatureBaum.vue'
|
||||
import ArchitekturTab from './ArchitekturTab.vue'
|
||||
import FlowsTab from './FlowsTab.vue'
|
||||
import PruefungTab from './PruefungTab.vue'
|
||||
import DokuTab from './DokuTab.vue'
|
||||
import ScanSeitenleiste from './ScanSeitenleiste.vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
ansicht: { type: String, default: 'features' },
|
||||
})
|
||||
|
||||
const projekt = ref(null)
|
||||
const fehler = ref('')
|
||||
const scanLaeuft = ref(false)
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
projekt.value = await fetchProjekt(props.name)
|
||||
fehler.value = ''
|
||||
} catch (e) {
|
||||
fehler.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function statusTick() {
|
||||
try {
|
||||
const s = await fetchScanStatus(props.name)
|
||||
const vorher = scanLaeuft.value
|
||||
scanLaeuft.value = s.laeuft
|
||||
if (vorher && !s.laeuft) await laden()
|
||||
} catch { /* nie fatal */ }
|
||||
}
|
||||
|
||||
const poll = usePolling(statusTick, () => scanLaeuft.value, 2000)
|
||||
onMounted(async () => {
|
||||
await laden()
|
||||
await statusTick()
|
||||
if (scanLaeuft.value) poll.start()
|
||||
})
|
||||
watch(() => props.name, async () => { projekt.value = null; await laden() })
|
||||
|
||||
const kernHtml = computed(() =>
|
||||
projekt.value ? marked.parse(projekt.value.kern.text || '') : '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||
<div v-else-if="projekt" class="rahmen" :class="{ mitLeiste: scanLaeuft }">
|
||||
<div>
|
||||
<section class="kern karte">
|
||||
<h2>{{ projekt.kern.titel || name }}</h2>
|
||||
<div class="kern-text" v-html="kernHtml"></div>
|
||||
<p v-if="projekt.drift.veraltet.length" class="drift-hinweis">
|
||||
⚠ {{ projekt.drift.veraltet.length }} Datei(en) seit dem letzten Scan geändert — Re-Scan empfohlen.
|
||||
</p>
|
||||
</section>
|
||||
<FeatureBaum v-if="ansicht === 'features'" :projekt="projekt" />
|
||||
<ArchitekturTab v-else-if="ansicht === 'architektur'" :projekt="projekt" />
|
||||
<FlowsTab v-else-if="ansicht === 'flows'" :projekt="projekt" />
|
||||
<DokuTab v-else-if="ansicht === 'doku'" :projekt="projekt" />
|
||||
<PruefungTab v-else :projekt="projekt" />
|
||||
</div>
|
||||
<ScanSeitenleiste v-if="scanLaeuft" :name="name" />
|
||||
</div>
|
||||
<p v-else class="laden">lädt…</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rahmen.mitLeiste { display: grid; grid-template-columns: 1fr 300px; gap: 1.2rem; }
|
||||
.kern { margin-bottom: 1.2rem; }
|
||||
.kern h2 { margin: 0 0 0.4rem; font-size: 1.2rem; }
|
||||
.kern-text :deep(p) { margin: 0.3rem 0; color: var(--dezent); }
|
||||
.drift-hinweis { color: var(--gelb); font-size: 0.88rem; margin: 0.6rem 0 0; }
|
||||
.fehler { color: var(--rot); }
|
||||
.laden { color: var(--dezent); }
|
||||
</style>
|
||||
66
frontend/src/components/ProjektKarte.vue
Normal file
66
frontend/src/components/ProjektKarte.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { starteScan } from '../api.js'
|
||||
|
||||
const props = defineProps({ projekt: { type: Object, required: true } })
|
||||
const emit = defineEmits(['oeffne', 'aktualisiert'])
|
||||
const meldung = ref('')
|
||||
|
||||
async function scan(art) {
|
||||
meldung.value = ''
|
||||
try {
|
||||
await starteScan(props.projekt.name, art)
|
||||
emit('aktualisiert')
|
||||
} catch (e) {
|
||||
meldung.value = e.message // 409: dirty tree, Rolle unkonfiguriert, …
|
||||
}
|
||||
}
|
||||
|
||||
function zeit(ts) {
|
||||
if (!ts) return 'nie gescannt'
|
||||
return new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="karte" :class="{ tot: projekt.fehler }">
|
||||
<div class="zeile">
|
||||
<a v-if="!projekt.fehler && projekt.hat_planer" href="#" class="name"
|
||||
@click.prevent="$emit('oeffne')">{{ projekt.name }}</a>
|
||||
<span v-else class="name">{{ projekt.name }}</span>
|
||||
<span v-if="projekt.scan_laeuft" class="badge gelb">läuft</span>
|
||||
<span v-else-if="projekt.fehler" class="badge rot">Fehler</span>
|
||||
<span v-else-if="!projekt.hat_planer" class="badge gelb">ungescannt</span>
|
||||
<span v-else-if="projekt.drift?.length" class="badge gelb">Drift: {{ projekt.drift.length }}</span>
|
||||
<span v-else class="badge gruen">aktuell</span>
|
||||
</div>
|
||||
<p class="detail" v-if="projekt.fehler">{{ projekt.fehler }}</p>
|
||||
<p class="detail" v-else>
|
||||
{{ projekt.pfad }} · letzter Scan: {{ zeit(projekt.letzter_scan_ts) }}
|
||||
</p>
|
||||
<p class="detail drift" v-if="projekt.drift?.length">veraltet: {{ projekt.drift.join(', ') }}</p>
|
||||
<div class="aktionen" v-if="!projekt.fehler">
|
||||
<button v-if="!projekt.hat_planer" class="primaer" :disabled="projekt.scan_laeuft"
|
||||
@click="scan('scan')">Scannen</button>
|
||||
<button v-else :disabled="projekt.scan_laeuft" @click="scan('rescan')">Re-Scan</button>
|
||||
</div>
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.karte {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.2rem;
|
||||
}
|
||||
.karte.tot { opacity: 0.7; }
|
||||
.zeile { display: flex; align-items: baseline; gap: 0.6rem; }
|
||||
.name { font-weight: 600; color: var(--text); text-decoration: none; }
|
||||
a.name:hover { color: var(--akzent); }
|
||||
.detail { color: var(--dezent); font-size: 0.85rem; margin: 0.3rem 0 0; overflow-wrap: anywhere; }
|
||||
.drift { color: var(--gelb); }
|
||||
.aktionen { margin-top: 0.7rem; }
|
||||
.meldung { color: var(--rot); font-size: 0.85rem; margin: 0.5rem 0 0; }
|
||||
</style>
|
||||
27
frontend/src/components/PruefungTab.vue
Normal file
27
frontend/src/components/PruefungTab.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
defineProps({ projekt: { type: Object, required: true } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pruefung">
|
||||
<dl>
|
||||
<dt>Tests</dt><dd><code>{{ projekt.pruefung.tests || '—' }}</code></dd>
|
||||
<dt>Lint</dt><dd><code>{{ projekt.pruefung.lint || '—' }}</code></dd>
|
||||
<dt>Start</dt><dd><code>{{ projekt.pruefung.start || '—' }}</code></dd>
|
||||
</dl>
|
||||
<ul v-if="projekt.pruefung.hinweise.length">
|
||||
<li v-for="(h, i) in projekt.pruefung.hinweise" :key="i">{{ h }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pruefung {
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 10px;
|
||||
padding: 0.9rem 1.2rem;
|
||||
}
|
||||
dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.3rem 1rem; margin: 0; }
|
||||
dt { font-weight: 600; }
|
||||
dd { margin: 0; }
|
||||
ul { color: var(--dezent); margin: 0.7rem 0 0; }
|
||||
</style>
|
||||
60
frontend/src/components/ScanSeitenleiste.vue
Normal file
60
frontend/src/components/ScanSeitenleiste.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchScanStatus } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
|
||||
const props = defineProps({ name: { type: String, required: true } })
|
||||
const status = ref(null)
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
status.value = await fetchScanStatus(props.name)
|
||||
} catch { /* nie fatal */ }
|
||||
}
|
||||
|
||||
const poll = usePolling(tick, () => status.value?.laeuft !== false, 2000)
|
||||
onMounted(async () => {
|
||||
await tick()
|
||||
poll.start()
|
||||
})
|
||||
|
||||
function dauer(s) {
|
||||
return `${Math.round(s)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="leiste">
|
||||
<h3>Scan läuft</h3>
|
||||
<template v-if="status">
|
||||
<dl class="stufen">
|
||||
<template v-for="(n, stufe) in status.stufen" :key="stufe">
|
||||
<dt>{{ stufe }}</dt><dd>{{ n }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<div v-if="status.agenten.length" class="agenten">
|
||||
<h4>Aktive Agenten</h4>
|
||||
<p v-for="a in status.agenten" :key="a.key" class="agent">
|
||||
<code>{{ a.label || a.key }}</code> · {{ dauer(a.runtime) }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="status.dead.length" class="dead">DEAD: {{ status.dead.join(', ') }}</p>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.leiste {
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 10px;
|
||||
padding: 0.9rem 1.1rem; align-self: start; position: sticky; top: 1rem;
|
||||
}
|
||||
.leiste h3 { margin: 0 0 0.5rem; font-size: 0.95rem; }
|
||||
.leiste h4 { margin: 0.7rem 0 0.3rem; font-size: 0.8rem; color: var(--dezent);
|
||||
text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.stufen { display: grid; grid-template-columns: 1fr max-content; gap: 0.15rem 0.8rem;
|
||||
font-size: 0.85rem; margin: 0; }
|
||||
.stufen dt { color: var(--dezent); }
|
||||
.stufen dd { margin: 0; text-align: right; }
|
||||
.agent { font-size: 0.78rem; margin: 0.2rem 0; overflow-wrap: anywhere; }
|
||||
.dead { color: var(--rot); font-size: 0.82rem; margin: 0.6rem 0 0; }
|
||||
</style>
|
||||
43
frontend/src/components/StartSeite.vue
Normal file
43
frontend/src/components/StartSeite.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchProjekte } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
import ProjektKarte from './ProjektKarte.vue'
|
||||
|
||||
defineEmits(['oeffne'])
|
||||
|
||||
const projekte = ref([])
|
||||
const fehler = ref('')
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
projekte.value = await fetchProjekte()
|
||||
fehler.value = ''
|
||||
} catch (e) {
|
||||
fehler.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
const poll = usePolling(laden, () => projekte.value.some((p) => p.scan_laeuft), 3000)
|
||||
onMounted(async () => {
|
||||
await laden()
|
||||
poll.start()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="fehler" class="fehler">{{ fehler }}</p>
|
||||
<p v-else-if="!projekte.length" class="leer">
|
||||
Keine Projekte — Pfade in <code>projekte.txt</code> eintragen (eine Zeile = ein Repo).
|
||||
</p>
|
||||
<div class="liste">
|
||||
<ProjektKarte v-for="p in projekte" :key="p.name" :projekt="p"
|
||||
@oeffne="$emit('oeffne', p.name)" @aktualisiert="laden(); poll.start()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.fehler { color: var(--rot); }
|
||||
.leer { color: var(--dezent); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user