update
This commit is contained in:
@@ -4,6 +4,8 @@ import { fetchProjekte } from './api.js'
|
||||
import SeitenLeiste from './components/SeitenLeiste.vue'
|
||||
import FeatureListe from './components/FeatureListe.vue'
|
||||
import KontextMenue from './components/KontextMenue.vue'
|
||||
import PruefPanel from './components/PruefPanel.vue'
|
||||
import { offen as pruefungOffen, starte as startePruefung, schliesse as schliessePruefung } from './composables/pruefung.js'
|
||||
|
||||
const projekte = ref([])
|
||||
const aktivesProjekt = ref(null)
|
||||
@@ -29,6 +31,9 @@ function umbenannt(alt, neu) {
|
||||
ladeProjekte()
|
||||
}
|
||||
|
||||
// Prüfung gehört zum Projekt — beim Wechsel wird sie verworfen
|
||||
watch(aktivesProjekt, () => { if (pruefungOffen.value) schliessePruefung() })
|
||||
|
||||
// Zuletzt aktives Projekt überlebt das Neuladen
|
||||
watch(aktivesProjekt, name => {
|
||||
if (name) localStorage.setItem('aktivesProjekt', name)
|
||||
@@ -51,7 +56,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="raster">
|
||||
<div class="raster" :class="{ pruefung: pruefungOffen }">
|
||||
<SeitenLeiste
|
||||
:projekte="projekte"
|
||||
:aktiv="aktivesProjekt"
|
||||
@@ -59,6 +64,12 @@ onMounted(async () => {
|
||||
@geaendert="ladeProjekte"
|
||||
@umbenannt="umbenannt"
|
||||
>
|
||||
<button
|
||||
v-if="aktivesProjekt"
|
||||
class="icon-knopf"
|
||||
title="Karten prüfen"
|
||||
@click="startePruefung(aktivesProjekt)"
|
||||
>⌾</button>
|
||||
<button class="icon-knopf" :title="dunkel ? 'Hellmodus' : 'Dunkelmodus'" @click="wechsleDarkMode">
|
||||
{{ dunkel ? '☀' : '☾' }}
|
||||
</button>
|
||||
@@ -70,6 +81,7 @@ onMounted(async () => {
|
||||
<p v-else-if="!meldung" class="leer">Projekt wählen oder anlegen.</p>
|
||||
</main>
|
||||
|
||||
<PruefPanel v-if="pruefungOffen" />
|
||||
<KontextMenue />
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,6 +92,7 @@ onMounted(async () => {
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.raster.pruefung { grid-template-columns: 220px 1fr 260px; }
|
||||
main {
|
||||
padding: 1.2rem 1.5rem;
|
||||
min-width: 0;
|
||||
|
||||
@@ -40,3 +40,9 @@ export const featureSpeichern = (projekt, id, roh) =>
|
||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'PUT', body: { roh } })
|
||||
export const featureLoeschen = (projekt, id) =>
|
||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' })
|
||||
|
||||
export const pruefungStarten = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/pruefung`, { method: 'POST' })
|
||||
export const fetchPruefung = (projekt) => req(`/projekte/${p(projekt)}/pruefung`)
|
||||
export const pruefungBeenden = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/pruefung`, { method: 'DELETE' })
|
||||
|
||||
@@ -3,12 +3,15 @@ import { nextTick, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { featureSpeichern } from '../api.js'
|
||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||
import { erledige } from '../composables/pruefung.js'
|
||||
import ModalBox from './ModalBox.vue'
|
||||
import ProblemHinweis from './ProblemHinweis.vue'
|
||||
|
||||
const props = defineProps({
|
||||
projekt: { type: String, required: true },
|
||||
feature: { type: Object, required: true },
|
||||
vollmodus: { type: Boolean, default: false }, // frisch angelegt: direkt im Vollmodus öffnen
|
||||
probleme: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['aktualisiert', 'entfernen'])
|
||||
|
||||
@@ -173,12 +176,59 @@ function punkte(text) {
|
||||
function wechsleAnsicht() {
|
||||
gross.value = !gross.value
|
||||
}
|
||||
|
||||
// Prüfung: fehlerhafte Karten öffnen sich, damit die Stellen sichtbar sind
|
||||
watch(() => props.probleme.length, n => { if (n) gross.value = true }, { immediate: true })
|
||||
|
||||
function problemeAn(feld, funktion = '', original = null) {
|
||||
return props.probleme.filter(p =>
|
||||
p.feld === feld &&
|
||||
(p.funktion || '') === funktion &&
|
||||
(original === null || p.original === original))
|
||||
}
|
||||
|
||||
// Vorschlag übernehmen: Titel und Ziel ganz ersetzen, bei Regeln und Testen die Zeile
|
||||
function anwenden(p) {
|
||||
const ziel = p.funktion ? s.value.funktionen.find(f => f.text === p.funktion) : s.value
|
||||
if (!ziel) return
|
||||
if (p.feld === 'titel') {
|
||||
if (p.funktion) ziel.text = p.vorschlag
|
||||
else ziel.titel = p.vorschlag
|
||||
} else if (p.feld === 'ziel') {
|
||||
ziel.ziel = p.vorschlag
|
||||
} else {
|
||||
const feld = p.feld === 'testen' ? 'testen' : 'regeln'
|
||||
ziel[feld] = ziel[feld]
|
||||
.split('\n')
|
||||
.map(z => (z.replace(/^[-*]\s*/, '').trim() === p.original ? `- ${p.vorschlag}` : z))
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
async function annehmen(p) {
|
||||
anwenden(p)
|
||||
erledige(p)
|
||||
await speichern()
|
||||
}
|
||||
|
||||
function ablehnen(p) {
|
||||
erledige(p)
|
||||
}
|
||||
|
||||
async function alleAnnehmen() {
|
||||
props.probleme.forEach(p => { anwenden(p); erledige(p) })
|
||||
await speichern()
|
||||
}
|
||||
|
||||
function alleAblehnen() {
|
||||
props.probleme.forEach(erledige)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="karte"
|
||||
:class="[s.art, { gross }]"
|
||||
:class="[s.art, { gross, fehlerhaft: probleme.length }]"
|
||||
@mousedown.middle.prevent
|
||||
@auxclick.middle.prevent="wechsleAnsicht"
|
||||
@contextmenu.prevent.stop="kartenMenue"
|
||||
@@ -207,7 +257,12 @@ function wechsleAnsicht() {
|
||||
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
|
||||
@click="wechsleAnsicht"
|
||||
>👁</button>
|
||||
<span v-if="probleme.length" class="alle">
|
||||
<button class="icon-knopf" title="Alle annehmen" @click="alleAnnehmen">✓</button>
|
||||
<button class="icon-knopf rot" title="Alle ablehnen" @click="alleAblehnen">✕</button>
|
||||
</span>
|
||||
</header>
|
||||
<ProblemHinweis :probleme="problemeAn('titel')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
|
||||
<section :class="{ leer: !s.ziel && !istAktiv('ziel') }">
|
||||
<p class="gruppe">Ziel</p>
|
||||
@@ -221,6 +276,7 @@ function wechsleAnsicht() {
|
||||
/>
|
||||
<div v-else-if="s.ziel" class="inhalt klickbar" v-html="md(s.ziel)" @click="starte('ziel')" />
|
||||
<p v-else-if="gross" class="hinweis" @click="starte('ziel')">Ziel ergänzen…</p>
|
||||
<ProblemHinweis :probleme="problemeAn('ziel')" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
</section>
|
||||
|
||||
<!-- Karten-Regeln nur in der ausführlichen Ansicht -->
|
||||
@@ -235,7 +291,14 @@ function wechsleAnsicht() {
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else-if="s.regeln" class="klickbar" @click="starte('regeln')">
|
||||
<div v-for="(r, k) in punkte(s.regeln)" :key="k" class="regel">{{ r }}</div>
|
||||
<template v-for="(r, k) in punkte(s.regeln)" :key="k">
|
||||
<div class="regel" :class="{ fehler: problemeAn('regeln', '', r).length }">{{ r }}</div>
|
||||
<ProblemHinweis
|
||||
:probleme="problemeAn('regeln', '', r)"
|
||||
@annehmen="annehmen"
|
||||
@ablehnen="ablehnen"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<p v-else-if="gross" class="hinweis" @click="starte('regeln')">Regeln ergänzen…</p>
|
||||
</section>
|
||||
@@ -260,8 +323,14 @@ function wechsleAnsicht() {
|
||||
@keyup.enter="$event.target.blur()"
|
||||
@keydown.esc="abbrechen"
|
||||
>
|
||||
<div v-else class="funktionstitel klickbar" @click="starte('funktion', i)">{{ f.text }}</div>
|
||||
<div
|
||||
v-else
|
||||
class="funktionstitel klickbar"
|
||||
:class="{ fehler: problemeAn('titel', f.text).length }"
|
||||
@click="starte('funktion', i)"
|
||||
>{{ f.text }}</div>
|
||||
</div>
|
||||
<ProblemHinweis :probleme="problemeAn('titel', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
|
||||
<!-- Ziel, Regeln und Testen nur im Vollmodus -->
|
||||
<div class="details">
|
||||
@@ -277,6 +346,7 @@ function wechsleAnsicht() {
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else class="inhalt klickbar" v-html="md(f.ziel)" @click="starte('ziel', i)" />
|
||||
<ProblemHinweis :probleme="problemeAn('ziel', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
</div>
|
||||
<button v-if="!f.ziel && !istAktiv('ziel', i)" class="hinzu" @click="starte('ziel', i)">+ Ziel</button>
|
||||
|
||||
@@ -292,7 +362,14 @@ function wechsleAnsicht() {
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else class="klickbar" @click="starte('regeln', i)">
|
||||
<div v-for="(r, k) in punkte(f.regeln)" :key="k" class="regel">{{ r }}</div>
|
||||
<template v-for="(r, k) in punkte(f.regeln)" :key="k">
|
||||
<div class="regel" :class="{ fehler: problemeAn('regeln', f.text, r).length }">{{ r }}</div>
|
||||
<ProblemHinweis
|
||||
:probleme="problemeAn('regeln', f.text, r)"
|
||||
@annehmen="annehmen"
|
||||
@ablehnen="ablehnen"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!f.regeln && !istAktiv('regeln', i)" class="hinzu" @click="starte('regeln', i)">+ Regeln</button>
|
||||
@@ -309,9 +386,16 @@ function wechsleAnsicht() {
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else class="klickbar" @click="starte('testen', i)">
|
||||
<div v-for="(schritt, k) in punkte(f.testen)" :key="k" class="schritt">
|
||||
<span class="nr">{{ k + 1 }}.</span>{{ schritt }}
|
||||
</div>
|
||||
<template v-for="(schritt, k) in punkte(f.testen)" :key="k">
|
||||
<div class="schritt" :class="{ fehler: problemeAn('testen', f.text, schritt).length }">
|
||||
<span class="nr">{{ k + 1 }}.</span>{{ schritt }}
|
||||
</div>
|
||||
<ProblemHinweis
|
||||
:probleme="problemeAn('testen', f.text, schritt)"
|
||||
@annehmen="annehmen"
|
||||
@ablehnen="ablehnen"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,6 +428,10 @@ function wechsleAnsicht() {
|
||||
<style scoped>
|
||||
.karte.kern { border-color: var(--akzent); }
|
||||
.karte.optional { border-color: var(--gruen); }
|
||||
/* Prüfung: fehlerhafte Karte und Stelle rot */
|
||||
.karte.fehlerhaft { border-color: var(--rot); }
|
||||
.fehler { color: var(--rot); }
|
||||
.alle { white-space: nowrap; }
|
||||
|
||||
/* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten,
|
||||
alle Details offen */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
|
||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||
import { problemeFuer } from '../composables/pruefung.js'
|
||||
import FeatureKarte from './FeatureKarte.vue'
|
||||
import ModalBox from './ModalBox.vue'
|
||||
|
||||
@@ -88,6 +89,7 @@ watch(() => props.projekt, laden, { immediate: true })
|
||||
:projekt="projekt"
|
||||
:feature="f"
|
||||
:vollmodus="f.key === neuKey"
|
||||
:probleme="problemeFuer(f.id)"
|
||||
@aktualisiert="aktualisiert"
|
||||
@entfernen="oeffneModal({ art: 'entfernen', feature: f })"
|
||||
/>
|
||||
|
||||
52
frontend/src/components/ProblemHinweis.vue
Normal file
52
frontend/src/components/ProblemHinweis.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
probleme: { type: Array, required: true },
|
||||
})
|
||||
const emit = defineEmits(['annehmen', 'ablehnen'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(p, i) in probleme" :key="i" class="hinweis-block" @click.stop>
|
||||
<div class="kopf">
|
||||
<p class="problem">{{ p.problem }}</p>
|
||||
<span class="knoepfe">
|
||||
<button class="icon-knopf" title="Annehmen" @click="emit('annehmen', p)">✓</button>
|
||||
<button class="icon-knopf rot" title="Ablehnen" @click="emit('ablehnen', p)">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="vorschlag">{{ p.vorschlag }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hinweis-block {
|
||||
border-left: 2px solid var(--rot);
|
||||
background: var(--rot-bg);
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: 0.25rem 0.5rem 0.35rem;
|
||||
margin: 0.15rem 0 0.3rem;
|
||||
min-width: 0;
|
||||
cursor: default;
|
||||
}
|
||||
.kopf {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.problem {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.35;
|
||||
color: var(--rot);
|
||||
}
|
||||
.knoepfe { white-space: nowrap; }
|
||||
.vorschlag {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
72
frontend/src/components/PruefPanel.vue
Normal file
72
frontend/src/components/PruefPanel.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { laeuft, prozent, fertig, gesamt, meldung, probleme, schliesse } from '../composables/pruefung.js'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="panel">
|
||||
<header>
|
||||
<p class="gruppe">Prüfung</p>
|
||||
<button class="icon-knopf" title="Schließen" @click="schliesse">✕</button>
|
||||
</header>
|
||||
|
||||
<p class="fortschritt">
|
||||
<template v-if="laeuft">{{ prozent }} % geprüft</template>
|
||||
<template v-else-if="gesamt">{{ gesamt }} Karten geprüft</template>
|
||||
<template v-else>Keine Karten</template>
|
||||
<span v-if="laeuft" class="dezent"> ({{ fertig }}/{{ gesamt }})</span>
|
||||
</p>
|
||||
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
|
||||
<p v-if="!probleme.length && !laeuft && !meldung" class="dezent">Keine Probleme gefunden.</p>
|
||||
|
||||
<ul>
|
||||
<li v-for="(p, i) in probleme" :key="i">
|
||||
<span class="stelle">{{ p.funktion || p.feature }} · {{ p.feld }}</span>
|
||||
<span class="problem">{{ p.problem }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--rand);
|
||||
padding: 1rem 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.gruppe {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dezent);
|
||||
margin: 0;
|
||||
}
|
||||
.fortschritt { margin: 0; font-size: 0.9rem; }
|
||||
.dezent { color: var(--dezent); font-size: 0.85rem; }
|
||||
ul { list-style: none; margin: 0.3rem 0 0; padding: 0; }
|
||||
li {
|
||||
border-top: 1px solid var(--rand);
|
||||
padding: 0.35rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.stelle {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--akzent);
|
||||
}
|
||||
.problem { font-size: 0.85rem; }
|
||||
</style>
|
||||
89
frontend/src/composables/pruefung.js
Normal file
89
frontend/src/composables/pruefung.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { pruefungStarten, fetchPruefung, pruefungBeenden } from '../api.js'
|
||||
|
||||
// Ein Prüflauf für das aktive Projekt; Zustand global, damit Sidebar,
|
||||
// Panel und Karten dieselben Probleme sehen.
|
||||
export const offen = ref(false)
|
||||
export const laeuft = ref(false)
|
||||
export const fertig = ref(0)
|
||||
export const gesamt = ref(0)
|
||||
export const meldung = ref('')
|
||||
export const probleme = ref([])
|
||||
|
||||
const erledigt = new Set() // angenommene oder abgelehnte Probleme kommen nicht zurück
|
||||
let projektName = null
|
||||
let uhr = null
|
||||
|
||||
export const prozent = computed(() =>
|
||||
gesamt.value ? Math.round((fertig.value / gesamt.value) * 100) : 0)
|
||||
|
||||
export function schluessel(p) {
|
||||
return `${p.feature}|${p.feld}|${p.funktion}|${p.original}`
|
||||
}
|
||||
|
||||
export function problemeFuer(featureId) {
|
||||
return probleme.value.filter(p => p.feature === featureId)
|
||||
}
|
||||
|
||||
function uebernehmen(stand) {
|
||||
laeuft.value = stand.laeuft
|
||||
fertig.value = stand.fertig
|
||||
gesamt.value = stand.gesamt
|
||||
meldung.value = stand.meldung
|
||||
probleme.value = stand.probleme.filter(p => !erledigt.has(schluessel(p)))
|
||||
}
|
||||
|
||||
async function abfragen() {
|
||||
try {
|
||||
uebernehmen(await fetchPruefung(projektName))
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
laeuft.value = false
|
||||
}
|
||||
if (!laeuft.value) stoppeUhr()
|
||||
}
|
||||
|
||||
function stoppeUhr() {
|
||||
clearInterval(uhr)
|
||||
uhr = null
|
||||
}
|
||||
|
||||
export async function starte(projekt) {
|
||||
projektName = projekt
|
||||
offen.value = true
|
||||
meldung.value = ''
|
||||
erledigt.clear()
|
||||
probleme.value = []
|
||||
try {
|
||||
uebernehmen(await pruefungStarten(projekt))
|
||||
} catch (e) {
|
||||
if (e.status !== 409) { // 409 = Lauf läuft schon; dann daran anhängen
|
||||
meldung.value = e.message
|
||||
laeuft.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
uebernehmen(await fetchPruefung(projekt))
|
||||
} catch (f) {
|
||||
meldung.value = f.message
|
||||
laeuft.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
stoppeUhr()
|
||||
uhr = setInterval(abfragen, 1000)
|
||||
}
|
||||
|
||||
// Angenommenes oder abgelehntes Problem verschwindet, die übrigen bleiben stehen
|
||||
export function erledige(problem) {
|
||||
erledigt.add(schluessel(problem))
|
||||
probleme.value = probleme.value.filter(p => schluessel(p) !== schluessel(problem))
|
||||
}
|
||||
|
||||
export async function schliesse() {
|
||||
stoppeUhr()
|
||||
offen.value = false
|
||||
probleme.value = []
|
||||
laeuft.value = false
|
||||
if (projektName) await pruefungBeenden(projektName).catch(() => {})
|
||||
}
|
||||
Reference in New Issue
Block a user