update
BIN
frontend/chatui.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,16 +1,19 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { fetchProjekte } from './api.js'
|
||||
import { fetchProjekte, fetchVerzeichnis } 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 ChatPanel from './components/ChatPanel.vue'
|
||||
import RecherchePanel from './components/RecherchePanel.vue'
|
||||
import ModalBox from './components/ModalBox.vue'
|
||||
import {
|
||||
laeuft as planungLaeuft,
|
||||
sicht as planungSicht,
|
||||
anzahl as planungAnzahl,
|
||||
meldung as planungMeldung,
|
||||
agentLaeuft, agentMeldung, agentBericht, agentSchritte, agentAktivitaet, agentUebernommen,
|
||||
lade as ladePlanung,
|
||||
starte as startePlanung,
|
||||
umsetzen as setzePlanungUm,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
prozent as pruefungProzent,
|
||||
starte as startePruefung,
|
||||
schliesse as schliessePruefung,
|
||||
verwerfeAlles as verwerfeVorschlaege,
|
||||
} from './composables/pruefung.js'
|
||||
|
||||
const projekte = ref([])
|
||||
@@ -32,16 +36,57 @@ const meldung = ref('')
|
||||
// wären zwei Panels für dieselbe Fläche.
|
||||
const chatOffen = ref(false)
|
||||
|
||||
// In der Übersicht schaut man nur nach. Wer etwas ändern will, wechselt in die Planung.
|
||||
const inPlanung = computed(() => planungLaeuft.value && planungSicht.value === 'planung')
|
||||
const nurAnsehen = computed(() => planungLaeuft.value && !inPlanung.value)
|
||||
// Drei Räume, und was in einem erlaubt ist, ist es im anderen nicht:
|
||||
//
|
||||
// Übersicht was das Projekt heute kann — nur ansehen
|
||||
// Planung Karten entwickeln, mit dem Quelltext als Beleg
|
||||
// Recherche im Web nachschlagen, ohne Karten und ohne Code
|
||||
//
|
||||
// Die Trennung ist keine Optik. Ein Planungsgespräch, das nebenbei im Netz sucht,
|
||||
// zieht Fremdes unbemerkt in einen Entwurf; eine Recherche, die die Karten kennt,
|
||||
// fängt an, sie umzuschreiben. Was aus einem Fund folgt, entscheidet die Person.
|
||||
const REITER = [
|
||||
{ id: 'uebersicht', text: 'Übersicht' },
|
||||
{ id: 'planung', text: 'Planung' },
|
||||
{ id: 'recherche', text: 'Recherche' },
|
||||
]
|
||||
const reiter = ref('uebersicht')
|
||||
|
||||
// Umlegen heißt: die andere Seite zeigen. Gibt es noch keine Planung, entsteht sie hier.
|
||||
function schalte() {
|
||||
if (!planungLaeuft.value) return startePlanung()
|
||||
planungSicht.value = inPlanung.value ? 'uebersicht' : 'planung'
|
||||
const inPlanung = computed(() => planungLaeuft.value && planungSicht.value === 'planung')
|
||||
// Geändert wird nur in der Planung. In der Recherche stehen die Karten daneben, damit
|
||||
// man beim Suchen sieht, worum es geht — angefasst wird dort nichts.
|
||||
const nurAnsehen = computed(() =>
|
||||
reiter.value === 'recherche' || (planungLaeuft.value && !inPlanung.value))
|
||||
|
||||
// Wer die Planung aufschlägt und noch keine hat, beginnt damit eine. Schlägt das
|
||||
// fehl, bleibt der Reiter stehen, wo er war — sonst stünde man vor leerer Fläche.
|
||||
async function zeige(ziel) {
|
||||
if (ziel === 'planung' && !planungLaeuft.value) {
|
||||
if (!await startePlanung()) return
|
||||
}
|
||||
reiter.value = ziel
|
||||
}
|
||||
|
||||
// `planungSicht` steuert die Markierungen auf den Karten und wird auch von der
|
||||
// Planung selbst gesetzt — etwa wenn nach einem Neustart eine offene dasteht.
|
||||
// Beide Richtungen abgleichen, damit es nur einen Zustand gibt, den man sieht.
|
||||
// Auch auf `planungLaeuft`: beim Projektwechsel liest die Planung neu und stellt
|
||||
// die Sicht dabei selbst. Ohne den zweiten Auslöser stünde die Recherche danach vor
|
||||
// dem Projektstand — bei diesem Projekt ein leeres Feld, weil alle Karten in der
|
||||
// Planung liegen.
|
||||
watch([reiter, planungLaeuft], ([r]) => {
|
||||
// Nur die Übersicht zeigt den heutigen Stand. Die Recherche steht neben der
|
||||
// Planung: gesucht wird zu dem, was gebaut werden soll, nicht zu dem, was war.
|
||||
// Ohne laufende Planung sind ohnehin beide Sichten dasselbe Verzeichnis.
|
||||
planungSicht.value = r === 'uebersicht' ? 'uebersicht' : 'planung'
|
||||
})
|
||||
watch(planungSicht, s => {
|
||||
// Wer recherchiert, bleibt dabei: ein Projektwechsel im Hintergrund darf den
|
||||
// laufenden Faden nicht wegziehen.
|
||||
if (reiter.value === 'recherche') return
|
||||
reiter.value = s === 'planung' ? 'planung' : 'uebersicht'
|
||||
})
|
||||
|
||||
const stand = ref(0)
|
||||
|
||||
function oeffneChat() {
|
||||
@@ -77,8 +122,8 @@ function umbenannt(alt, neu) {
|
||||
ladeProjekte()
|
||||
}
|
||||
|
||||
// Prüfung gehört zum Projekt — beim Wechsel wird sie verworfen
|
||||
watch(aktivesProjekt, () => { if (pruefungOffen.value) schliessePruefung() })
|
||||
// Prüfung und Vorschläge gehören zum Projekt — beim Wechsel wird alles verworfen
|
||||
watch(aktivesProjekt, () => verwerfeVorschlaege())
|
||||
watch(aktivesProjekt, n => ladePlanung(n))
|
||||
|
||||
// Planen heißt, die Karten mit der KI zu entwickeln. Das Gespräch ist dabei kein
|
||||
@@ -93,8 +138,40 @@ watch(planungSicht, s => {
|
||||
chatOffen.value = true
|
||||
})
|
||||
|
||||
// Umsetzen und Abbrechen ändern die Karten — die Liste muss neu lesen.
|
||||
// Die Recherche nimmt die ganze Breite: sie hat keine Karten daneben, an denen ein
|
||||
// Panel etwas zu suchen hätte.
|
||||
watch(reiter, r => {
|
||||
if (r !== 'recherche') return
|
||||
if (pruefungOffen.value) schliessePruefung()
|
||||
chatOffen.value = false
|
||||
})
|
||||
|
||||
// Erst wenn der Agent fertig ist, wurde wirklich übernommen — dann neu lesen.
|
||||
watch(agentUebernommen, u => { if (u) stand.value++ })
|
||||
|
||||
// Jede Kartenänderung kann die Markierungen verschieben — die Planung liest nach.
|
||||
// Sonst fehlt einer frisch angepassten Karte ihr „Geändert", bis jemand neu lädt.
|
||||
function kartenGeaendert() {
|
||||
if (planungLaeuft.value) ladePlanung(aktivesProjekt.value)
|
||||
}
|
||||
|
||||
// Umsetzen überschreibt den Projektstand, Verwerfen wirft die Planung weg —
|
||||
// beides gibt es nicht zurück. Ein Klick daneben wäre teuer, darum die Rückfrage.
|
||||
const planungsFrage = ref(null) // 'umsetzen' | 'abbrechen'
|
||||
|
||||
// Die Rückfrage sagt ehrlich, was passiert: mit Arbeitsverzeichnis implementiert
|
||||
// Claude die Änderungen gleich mit.
|
||||
const verzeichnisPfad = ref('')
|
||||
async function frageUmsetzen() {
|
||||
verzeichnisPfad.value = ''
|
||||
try {
|
||||
verzeichnisPfad.value = (await fetchVerzeichnis(aktivesProjekt.value)).pfad
|
||||
} catch { /* ohne Antwort bleibt die Frage schlicht */ }
|
||||
planungsFrage.value = 'umsetzen'
|
||||
}
|
||||
|
||||
async function planungBeenden(wie) {
|
||||
planungsFrage.value = null
|
||||
const ok = wie === 'umsetzen' ? await setzePlanungUm() : await brichPlanungAb()
|
||||
if (ok) stand.value++
|
||||
}
|
||||
@@ -132,7 +209,10 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="raster" :class="{ pruefung: pruefungOffen || chatOffen }">
|
||||
<div
|
||||
class="raster"
|
||||
:class="{ pruefung: pruefungOffen || chatOffen || reiter === 'recherche' }"
|
||||
>
|
||||
<SeitenLeiste
|
||||
:projekte="projekte"
|
||||
:aktiv="aktivesProjekt"
|
||||
@@ -142,28 +222,45 @@ onMounted(async () => {
|
||||
/>
|
||||
|
||||
<main>
|
||||
<!-- Ganz oben, über allem: der Raum, in dem man steht. Er entscheidet, was die
|
||||
KI darunter darf, und das gehört nicht in eine Werkzeugleiste versteckt. -->
|
||||
<nav v-if="aktivesProjekt" class="reiter">
|
||||
<button
|
||||
v-for="r in REITER"
|
||||
:key="r.id"
|
||||
:class="{ an: reiter === r.id }"
|
||||
:title="r.id === 'planung' && !planungLaeuft ? 'Planung beginnen' : ''"
|
||||
@click="zeige(r.id)"
|
||||
>
|
||||
{{ r.text }}
|
||||
<span v-if="r.id === 'planung' && planungAnzahl" class="zahl">{{ planungAnzahl }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
<p v-if="planungMeldung" class="meldung">{{ planungMeldung }}</p>
|
||||
<!-- Der Agent arbeitet auf der Planung; übernommen wird erst nach Erfolg.
|
||||
Die Schritte kommen aus dem Plan und werden abgehakt, sobald er sie meldet. -->
|
||||
<div v-if="agentLaeuft" class="agentzeile">
|
||||
<p class="kopfzeile"><span class="puls" /> Claude implementiert die Planung …</p>
|
||||
<ul class="schritte">
|
||||
<li v-for="s in agentSchritte" :key="s.text" :class="{ fertig: s.fertig }">
|
||||
<span class="haken">{{ s.fertig ? '✓' : '○' }}</span>{{ s.text }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="agentAktivitaet" class="tut">{{ agentAktivitaet }}</p>
|
||||
</div>
|
||||
<div v-else-if="agentMeldung" class="agentzeile" :class="{ fehl: agentMeldung.startsWith('Agent:') }">
|
||||
{{ agentMeldung }}
|
||||
<details v-if="agentBericht"><summary>Bericht</summary><pre>{{ agentBericht }}</pre></details>
|
||||
</div>
|
||||
<FeatureListe
|
||||
v-if="aktivesProjekt"
|
||||
:projekt="aktivesProjekt"
|
||||
:projekte="projekte"
|
||||
:stand="stand"
|
||||
@geaendert="kartenGeaendert"
|
||||
>
|
||||
<template #werkzeuge>
|
||||
<!-- Ein Schalter, zwei Stellungen. Der Text nennt, was gerade zu sehen ist.
|
||||
Er steht immer da: „wo bin ich" ist auch dann eine Frage, wenn noch
|
||||
nichts geplant wird. Wer dann umlegt, beginnt eine Planung. -->
|
||||
<button
|
||||
class="schalter" :class="{ planung: inPlanung }"
|
||||
:title="inPlanung ? 'Wechseln zu Übersicht'
|
||||
: (planungLaeuft ? 'Wechseln zu Planung' : 'Planung beginnen')"
|
||||
@click="schalte"
|
||||
>
|
||||
⇄ {{ inPlanung ? 'Planung' : 'Übersicht' }}
|
||||
<span v-if="inPlanung && planungAnzahl" class="zahl">{{ planungAnzahl }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Jedes Werkzeug hier ändert Karten. Die Übersicht sagt nur, was das Projekt
|
||||
heute kann — dort wird nichts entschieden und nichts angefasst. -->
|
||||
<template v-if="!nurAnsehen">
|
||||
@@ -175,8 +272,8 @@ onMounted(async () => {
|
||||
>✎</button>
|
||||
<button class="icon-knopf" title="Stil prüfen" @click="startePruefung(aktivesProjekt, 'stil')">⌾</button>
|
||||
<template v-if="planungLaeuft">
|
||||
<button class="icon-knopf" title="Planung umsetzen" @click="planungBeenden('umsetzen')">▶</button>
|
||||
<button class="icon-knopf rot" title="Planung verwerfen" @click="planungBeenden('abbrechen')">✕</button>
|
||||
<button class="icon-knopf" title="Planung umsetzen" @click="frageUmsetzen">▶</button>
|
||||
<button class="icon-knopf rot" title="Planung verwerfen" @click="planungsFrage = 'abbrechen'">✕</button>
|
||||
</template>
|
||||
<span v-if="pruefungLaeuft" class="fortschritt">{{ pruefungProzent }} %</span>
|
||||
</template>
|
||||
@@ -189,13 +286,38 @@ onMounted(async () => {
|
||||
<p v-else-if="!meldung" class="leer">Projekt wählen oder anlegen.</p>
|
||||
</main>
|
||||
|
||||
<PruefPanel v-if="pruefungOffen" />
|
||||
<!-- Eine rechte Spalte, drei mögliche Bewohner. Der Reiter entscheidet zuerst:
|
||||
wer recherchiert, sieht die Recherche, nicht ein Panel von vorhin. -->
|
||||
<RecherchePanel
|
||||
v-if="reiter === 'recherche' && aktivesProjekt"
|
||||
:projekt="aktivesProjekt"
|
||||
/>
|
||||
<PruefPanel v-else-if="pruefungOffen" />
|
||||
<ChatPanel
|
||||
v-else-if="chatOffen && aktivesProjekt"
|
||||
:projekt="aktivesProjekt"
|
||||
@schliessen="chatOffen = false"
|
||||
/>
|
||||
<KontextMenue />
|
||||
|
||||
<ModalBox
|
||||
v-if="planungsFrage === 'umsetzen'"
|
||||
titel="Planung umsetzen"
|
||||
:frage="verzeichnisPfad
|
||||
? `Claude implementiert die Änderungen in ${verzeichnisPfad}. Übernommen wird erst, wenn er fertig ist. Starten?`
|
||||
: 'Die geplanten Karten werden der neue Projektstand. Umsetzen?'"
|
||||
knopf="Umsetzen"
|
||||
@bestaetigt="planungBeenden('umsetzen')"
|
||||
@abbrechen="planungsFrage = null"
|
||||
/>
|
||||
<ModalBox
|
||||
v-else-if="planungsFrage === 'abbrechen'"
|
||||
titel="Planung verwerfen"
|
||||
frage="Alle geplanten Änderungen gehen verloren. Verwerfen?"
|
||||
knopf="Verwerfen"
|
||||
@bestaetigt="planungBeenden('abbrechen')"
|
||||
@abbrechen="planungsFrage = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -232,7 +354,8 @@ main {
|
||||
.raster.pruefung > aside { grid-area: leiste; }
|
||||
.raster.pruefung > main { grid-area: inhalt; }
|
||||
.raster.pruefung > .panel,
|
||||
.raster.pruefung > .chat { grid-area: panel; }
|
||||
.raster.pruefung > .chat,
|
||||
.raster.pruefung > .recherche { grid-area: panel; }
|
||||
}
|
||||
|
||||
/* Schmale Fenster: die Projektliste legt sich als Leiste nach oben */
|
||||
@@ -256,24 +379,76 @@ main {
|
||||
}
|
||||
.icon-knopf.an { color: var(--akzent); background: var(--code-bg); }
|
||||
|
||||
/* Ein Schalter statt zweier Reiter. Beschriftet mit dem, was zu sehen ist —
|
||||
das Pfeilpaar sagt, dass ein Klick die andere Seite zeigt. */
|
||||
.schalter {
|
||||
border: 1px solid var(--rand);
|
||||
/* Drei Reiter auf einer Linie. Der aktive trägt den Strich — wo man steht, liest
|
||||
man an einer Kante ab, nicht an einer Färbung, die auch nur Betonung sein könnte. */
|
||||
.reiter {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
border-bottom: 1px solid var(--rand);
|
||||
margin: -0.2rem 0 1rem;
|
||||
}
|
||||
.reiter button {
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--dezent);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
margin-right: 0.3rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
margin-bottom: -1px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.86rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schalter.planung {
|
||||
color: var(--akzent-text);
|
||||
background: var(--akzent);
|
||||
border-color: var(--akzent);
|
||||
.reiter button:hover { color: var(--text); }
|
||||
.reiter button.an {
|
||||
color: var(--akzent);
|
||||
border-bottom-color: var(--akzent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.reiter .zahl {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Der Agent meldet sich in einem Kasten über den Karten */
|
||||
.agentzeile {
|
||||
max-width: 100rem;
|
||||
margin: 0 auto 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--dezent);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 0.5rem 0.8rem;
|
||||
}
|
||||
.agentzeile .kopfzeile { margin: 0; color: var(--text); }
|
||||
.schritte { list-style: none; margin: 0.3rem 0 0; padding: 0; }
|
||||
.schritte li { padding: 0.06rem 0; }
|
||||
.schritte li.fertig { color: var(--gruen); }
|
||||
.schritte .haken { display: inline-block; width: 1.2rem; }
|
||||
.tut {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agentzeile.fehl { color: var(--rot); }
|
||||
.agentzeile pre { white-space: pre-wrap; }
|
||||
.agentzeile summary { cursor: pointer; color: var(--akzent); }
|
||||
.puls {
|
||||
display: inline-block;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: var(--akzent);
|
||||
animation: puls 1.2s ease-in-out infinite;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
@keyframes puls {
|
||||
0%, 100% { opacity: 0.25; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.schalter .zahl { margin-left: 0.3rem; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -41,8 +41,6 @@ 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 featureVerschieben = (projekt, id, ziel) =>
|
||||
req(`/projekte/${p(projekt)}/features/${p(id)}/projekt`, { method: 'PUT', body: { ziel } })
|
||||
|
||||
|
||||
export const pruefungStarten = (projekt, art = 'stil') =>
|
||||
@@ -61,9 +59,23 @@ export const planungAbbrechen = (projekt) =>
|
||||
export const planungZuruecksetzen = (projekt, titel) =>
|
||||
req(`/projekte/${p(projekt)}/planung/zuruecksetzen`, { method: 'POST', body: { titel } })
|
||||
|
||||
export const fetchVerzeichnis = (projekt) => req(`/projekte/${p(projekt)}/verzeichnis`)
|
||||
export const verzeichnisSetzen = (projekt, pfad) =>
|
||||
req(`/projekte/${p(projekt)}/verzeichnis`, { method: 'PUT', body: { pfad } })
|
||||
export const fetchAuftrag = (projekt) => req(`/projekte/${p(projekt)}/auftrag`)
|
||||
export const fetchOrdner = (pfad = '') => req(`/system/ordner?pfad=${p(pfad)}`)
|
||||
|
||||
export const fetchChat = (projekt) => req(`/projekte/${p(projekt)}/chat`)
|
||||
export const chatFragen = (projekt, text) =>
|
||||
req(`/projekte/${p(projekt)}/chat`, { method: 'POST', body: { text } })
|
||||
export const chatLeeren = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/chat`, { method: 'DELETE' })
|
||||
|
||||
export const fetchRecherche = (projekt) => req(`/projekte/${p(projekt)}/recherche`)
|
||||
export const rechercheAnlegen = (projekt) =>
|
||||
req(`/projekte/${p(projekt)}/recherche`, { method: 'POST' })
|
||||
export const rechercheFragen = (projekt, id, text) =>
|
||||
req(`/projekte/${p(projekt)}/recherche/${p(id)}`, { method: 'POST', body: { text } })
|
||||
export const rechercheSchliessen = (projekt, id) =>
|
||||
req(`/projekte/${p(projekt)}/recherche/${p(id)}`, { method: 'DELETE' })
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ input, textarea {
|
||||
}
|
||||
.badge.blau { color: var(--akzent); background: var(--code-bg); }
|
||||
.badge.gruen { color: var(--gruen); background: var(--gruen-bg); }
|
||||
.badge.gold { color: var(--gold); background: var(--gold-bg); }
|
||||
|
||||
pre {
|
||||
background: var(--code-bg);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { fetchChat, chatFragen, chatLeeren } from '../api.js'
|
||||
import { ergaenze } from '../composables/pruefung.js'
|
||||
import { starteUhr, stoppeUhr } from '../composables/uhr.js'
|
||||
import { lies, setze } from '../composables/entwuerfe.js'
|
||||
import ModalBox from './ModalBox.vue'
|
||||
|
||||
// Das Gespräch ändert die Karten nicht selbst. Es legt Vorschläge daneben — dieselben,
|
||||
// die auch die Prüfung erzeugt. Angenommen wird einzeln, auf der Karte.
|
||||
@@ -13,8 +16,17 @@ const props = defineProps({
|
||||
const emit = defineEmits(['schliessen'])
|
||||
|
||||
const verlauf = ref([])
|
||||
const entwurf = ref('')
|
||||
// Der Entwurf liegt außerhalb der Komponente: sie verschwindet beim Reiterwechsel,
|
||||
// der halb getippte Satz soll es nicht.
|
||||
const schluessel = computed(() => `chat:${props.projekt}`)
|
||||
const entwurf = computed({
|
||||
get: () => lies(schluessel.value),
|
||||
set: (t) => setze(schluessel.value, t),
|
||||
})
|
||||
const laeuft = ref(false)
|
||||
// Darf die KI nachschlagen, dauert eine Antwort statt Sekunden auch Minuten. Ohne
|
||||
// laufende Zahl sieht ein langer Lauf aus wie ein hängender.
|
||||
const sekunden = ref(0)
|
||||
const meldung = ref('')
|
||||
const liste = ref(null)
|
||||
const feld = ref(null)
|
||||
@@ -42,6 +54,8 @@ async function senden() {
|
||||
if (!text || laeuft.value) return
|
||||
meldung.value = ''
|
||||
laeuft.value = true
|
||||
sekunden.value = 0
|
||||
starteUhr('chatUhr', () => (sekunden.value += 1), 1000)
|
||||
// Die eigene Zeile sofort zeigen; auf die Antwort wartet man sonst vor leerem Feld
|
||||
verlauf.value = [...verlauf.value, { rolle: 'person', text }]
|
||||
entwurf.value = ''
|
||||
@@ -55,12 +69,16 @@ async function senden() {
|
||||
entwurf.value = text // nichts verlieren, wenn es schiefgeht
|
||||
verlauf.value = verlauf.value.slice(0, -1)
|
||||
}
|
||||
stoppeUhr('chatUhr')
|
||||
laeuft.value = false
|
||||
nachUnten()
|
||||
feld.value?.focus()
|
||||
}
|
||||
|
||||
// Ein Klick löscht das ganze Gespräch — dafür gibt es eine Rückfrage.
|
||||
const frage = ref(false)
|
||||
async function leeren() {
|
||||
frage.value = false
|
||||
try {
|
||||
await chatLeeren(props.projekt)
|
||||
verlauf.value = []
|
||||
@@ -80,7 +98,7 @@ watch(() => props.projekt, laden, { immediate: true })
|
||||
class="icon-knopf"
|
||||
:disabled="!verlauf.length || laeuft"
|
||||
title="Gespräch verwerfen"
|
||||
@click="leeren"
|
||||
@click="frage = true"
|
||||
>⌫</button>
|
||||
<button class="icon-knopf" title="Gespräch schließen" @click="emit('schliessen')">✕</button>
|
||||
</header>
|
||||
@@ -93,24 +111,33 @@ watch(() => props.projekt, laden, { immediate: true })
|
||||
<div v-if="e.rolle === 'ki'" class="inhalt" v-html="md(e.text)" />
|
||||
<div v-else class="inhalt">{{ e.text }}</div>
|
||||
</div>
|
||||
<p v-if="laeuft" class="denkt">denkt nach …</p>
|
||||
<p v-if="laeuft" class="denkt">denkt nach … {{ sekunden }} s</p>
|
||||
</div>
|
||||
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
|
||||
<div class="eingabe">
|
||||
<!-- Tippen bleibt möglich, während die Antwort läuft — nur Senden wartet -->
|
||||
<textarea
|
||||
ref="feld"
|
||||
v-model="entwurf"
|
||||
rows="3"
|
||||
placeholder="Was soll das Projekt können?"
|
||||
:disabled="laeuft"
|
||||
@keydown.enter.exact.prevent="senden"
|
||||
/>
|
||||
<button class="primaer" :disabled="!entwurf.trim() || laeuft" @click="senden">
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ModalBox
|
||||
v-if="frage"
|
||||
titel="Gespräch verwerfen"
|
||||
frage="Der ganze Gesprächsverlauf wird gelöscht. Verwerfen?"
|
||||
knopf="Verwerfen"
|
||||
@bestaetigt="leeren"
|
||||
@abbrechen="frage = false"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { featureSpeichern } from '../api.js'
|
||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||
@@ -12,14 +12,15 @@ const props = defineProps({
|
||||
feature: { type: Object, required: true },
|
||||
vollmodus: { type: Boolean, default: false }, // frisch angelegt: direkt im Vollmodus öffnen
|
||||
probleme: { type: Array, default: () => [] },
|
||||
verschiebbar: { type: Boolean, default: false }, // es gibt ein anderes Projekt
|
||||
// {art, funktionen: {titel: 'neu'|'weg'|'geaendert'}, kopf} — null außerhalb der Planung
|
||||
planung: { type: Object, default: null },
|
||||
// Übersicht während einer Planung: der Projektstand ist Ansicht, nicht
|
||||
// Arbeitsfläche. Ohne die Sperre landete jede Änderung still in der Kopie.
|
||||
nurLesen: { type: Boolean, default: false },
|
||||
// Eine andere Karte wurde geöffnet — höchstens eine ist groß
|
||||
zuklappen: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['aktualisiert', 'entfernen', 'verschieben', 'zuruecksetzen'])
|
||||
const emit = defineEmits(['aktualisiert', 'entfernen', 'zuruecksetzen', 'modus'])
|
||||
|
||||
const PLANUNG_TITEL = { neu: 'Neu', weg: 'Wird gestrichen', geaendert: 'Geändert' }
|
||||
|
||||
@@ -44,6 +45,7 @@ function kopie(sekt) {
|
||||
}
|
||||
}
|
||||
|
||||
const wurzel = ref(null) // die Karte selbst — zum Hinscrollen beim Aufklappen
|
||||
const s = ref(kopie(props.feature.sektionen))
|
||||
watch(() => props.feature, f => { s.value = kopie(f.sektionen) })
|
||||
|
||||
@@ -118,21 +120,9 @@ function funktionsMenue(e, i) {
|
||||
// Kompakt wird gelesen, nicht angepasst. Sonst ließe sich hier eine Funktion
|
||||
// entfernen, während die Karte sagt: Anpassen nur im Vollmodus.
|
||||
if (!gross.value) return kartenMenue(e)
|
||||
const eintraege = []
|
||||
if (i > 0) eintraege.push({ text: '▲ Nach oben', aktion: () => schiebe(i, -1) })
|
||||
if (i < s.value.funktionen.length - 1) {
|
||||
eintraege.push({ text: '▼ Nach unten', aktion: () => schiebe(i, 1) })
|
||||
}
|
||||
eintraege.push({ text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) })
|
||||
oeffneMenue(e, eintraege)
|
||||
}
|
||||
|
||||
async function schiebe(i, richtung) {
|
||||
const liste = s.value.funktionen
|
||||
const j = i + richtung
|
||||
if (j < 0 || j >= liste.length) return
|
||||
;[liste[i], liste[j]] = [liste[j], liste[i]]
|
||||
await speichern()
|
||||
oeffneMenue(e, [
|
||||
{ text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) },
|
||||
])
|
||||
}
|
||||
|
||||
function kartenMenue(e) {
|
||||
@@ -141,9 +131,6 @@ function kartenMenue(e) {
|
||||
if (gross.value) {
|
||||
eintraege.push({ text: '+ Funktion erstellen', aktion: () => (modal.value = { art: 'erstellen' }) })
|
||||
}
|
||||
if (props.verschiebbar) {
|
||||
eintraege.push({ text: '→ Feature verschieben', aktion: () => emit('verschieben') })
|
||||
}
|
||||
eintraege.push({ text: '✕ Feature entfernen', aktion: () => emit('entfernen') })
|
||||
oeffneMenue(e, eintraege)
|
||||
}
|
||||
@@ -162,13 +149,22 @@ async function funktionEntfernen() {
|
||||
}
|
||||
|
||||
function wechsleArt() {
|
||||
if (props.nurLesen) return
|
||||
if (s.value.art === 'vision') return // die Vision ist keine Einstufung
|
||||
s.value.art = s.value.art === 'kern' ? 'optional' : 'kern'
|
||||
speichern()
|
||||
}
|
||||
|
||||
// Offen heißt: bestellt, aber noch nicht gebaut. Die Vorwärtsprüfung klagt eine
|
||||
// offene Funktion nicht an — sie nimmt sie ab, sobald Code dazu belegt ist.
|
||||
// kern blau, optional grün, vision gold
|
||||
function badgeFarbe(art) {
|
||||
return art === 'vision' ? 'gold' : art === 'kern' ? 'blau' : 'gruen'
|
||||
}
|
||||
|
||||
// Offen heißt: noch nicht bewiesen. Zurücksetzen ist die Art, eine kaputte
|
||||
// Umsetzung zu melden — der nächste Agentenlauf prüft die Funktion gegen ihre
|
||||
// Testschritte und hakt sie erst ab, wenn sie stimmt.
|
||||
function wechsleStand(i) {
|
||||
if (props.nurLesen) return
|
||||
const f = s.value.funktionen[i]
|
||||
f.stand = f.stand === 'offen' ? 'gebaut' : 'offen'
|
||||
speichern()
|
||||
@@ -213,24 +209,49 @@ async function speichern() {
|
||||
}
|
||||
}
|
||||
|
||||
function zeilen(text) {
|
||||
return Math.max(2, text.split('\n').length)
|
||||
// Das Eingabefeld wächst mit dem Inhalt — auch mit umbrochenen Zeilen. Eine
|
||||
// Zeilenzahl aus \n greift zu kurz: lange Zeilen brechen um, und der Text
|
||||
// verschwand hinter einem Rollbalken.
|
||||
function wachse(el) {
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight + 2}px`
|
||||
}
|
||||
const vWachsend = { mounted: wachse, updated: wachse }
|
||||
|
||||
// Blocktext („- Eintrag“ je Zeile) für die Listen-Anzeige zerlegen
|
||||
function punkte(text) {
|
||||
return text.split('\n').map(z => z.replace(/^[-*]\s*/, '').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Kompaktmodus zum Lesen, Vollmodus zum Anpassen — MMB auf die Karte
|
||||
// oder das Augen-Icon schaltet um
|
||||
// Kompaktmodus zum Lesen, Vollmodus zum Anpassen. LMB auf die kompakte Karte
|
||||
// öffnet sie, MMB schaltet in beide Richtungen. Es ist höchstens eine offen —
|
||||
// die Liste klappt die anderen zu.
|
||||
function wechsleAnsicht() {
|
||||
gross.value = !gross.value
|
||||
}
|
||||
|
||||
function oeffne() {
|
||||
if (!gross.value) gross.value = true
|
||||
}
|
||||
|
||||
watch(() => props.zuklappen, zu => { if (zu && gross.value) gross.value = false })
|
||||
|
||||
// Prüfung: fehlerhafte Karten öffnen sich, damit die Stellen sichtbar sind
|
||||
watch(() => props.probleme.length, n => { if (n) gross.value = true }, { immediate: true })
|
||||
|
||||
// Die Liste ordnet aufgeklappte Karten an den Anfang ihrer Zeile — dafür muss
|
||||
// sie wissen, welche gerade groß sind. Danach holt die Seite die Karte ins Bild:
|
||||
// nach dem Umsortieren steht sie sonst außerhalb des Sichtbereichs.
|
||||
async function insBild() {
|
||||
await nextTick()
|
||||
wurzel.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
watch(gross, g => {
|
||||
emit('modus', g)
|
||||
if (g) insBild()
|
||||
}, { immediate: true })
|
||||
onMounted(() => { if (gross.value) insBild() })
|
||||
|
||||
function problemeAn(feld, funktion = '', original = null) {
|
||||
return props.probleme.filter(p =>
|
||||
p.feld === feld &&
|
||||
@@ -245,6 +266,12 @@ function problemeFeld(feld, funktion = '') {
|
||||
p.feld === feld && (p.funktion || '') === funktion && p.ganzes_feld)
|
||||
}
|
||||
|
||||
// Ein leeres Feld wird nicht gerendert — ein Vorschlag dazu hätte sonst keinen Ort,
|
||||
// und man müsste blind annehmen. Also den Block auch für den Vorschlag aufmachen.
|
||||
function hatVorschlag(feld, funktion = '') {
|
||||
return problemeAn(feld, funktion).length > 0 || problemeFeld(feld, funktion).length > 0
|
||||
}
|
||||
|
||||
// Bleibt die alte Zeile im Vorschlag stehen, kommt nur etwas dazu — sie ist in Ordnung
|
||||
function fehlerAn(feld, funktion = '', original = null) {
|
||||
return problemeAn(feld, funktion, original).some(p => vorschlagsArt(p) !== 'ergaenzen')
|
||||
@@ -343,9 +370,11 @@ function alleAblehnen() {
|
||||
|
||||
<template>
|
||||
<article
|
||||
ref="wurzel"
|
||||
class="karte"
|
||||
:data-feature="feature.id"
|
||||
:class="[s.art, { gross, fehlerhaft: probleme.length }]"
|
||||
@click="oeffne"
|
||||
@mousedown.middle.prevent
|
||||
@auxclick.middle.prevent="wechsleAnsicht"
|
||||
@contextmenu.prevent.stop="kartenMenue"
|
||||
@@ -362,22 +391,17 @@ function alleAblehnen() {
|
||||
>
|
||||
<h3 v-else :title="gross ? 'Klicken zum Bearbeiten' : null" @click="starte('titel')">{{ s.titel }}</h3>
|
||||
<button
|
||||
v-if="gross"
|
||||
v-if="gross && s.art !== 'vision'"
|
||||
class="badge"
|
||||
:class="[s.art === 'kern' ? 'blau' : 'gruen', { fehler: fehlerAn('badge') }]"
|
||||
:class="[badgeFarbe(s.art), { fehler: fehlerAn('badge') }]"
|
||||
title="Klicken zum Umschalten"
|
||||
@click="wechsleArt"
|
||||
>{{ s.art }}</button>
|
||||
<span
|
||||
v-else
|
||||
class="badge"
|
||||
:class="[s.art === 'kern' ? 'blau' : 'gruen', { fehler: fehlerAn('badge') }]"
|
||||
:class="[badgeFarbe(s.art), { fehler: fehlerAn('badge') }]"
|
||||
>{{ s.art }}</span>
|
||||
<button
|
||||
class="icon-knopf"
|
||||
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
|
||||
@click="wechsleAnsicht"
|
||||
>👁</button>
|
||||
<span v-if="probleme.length" class="alle">
|
||||
<span class="anzahl">{{ probleme.length }}</span>
|
||||
<button
|
||||
@@ -407,7 +431,8 @@ function alleAblehnen() {
|
||||
v-if="istAktiv('ziel')"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
v-wachsend
|
||||
rows="2"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
@@ -424,11 +449,12 @@ function alleAblehnen() {
|
||||
v-if="istAktiv('regeln')"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
v-wachsend
|
||||
rows="2"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else-if="s.regeln" class="klickbar" @click="starte('regeln')">
|
||||
<div v-else-if="s.regeln" class="klickbar regelliste" @click="starte('regeln')">
|
||||
<template v-for="(r, k) in punkte(s.regeln)" :key="k">
|
||||
<div class="regel" :class="{ fehler: fehlerAn('regeln', '', r) }">{{ r }}</div>
|
||||
<ProblemHinweis
|
||||
@@ -477,7 +503,7 @@ function alleAblehnen() {
|
||||
v-if="gross"
|
||||
class="stand"
|
||||
:class="{ offen: f.stand === 'offen', fehler: fehlerAn('stand', f.text) }"
|
||||
:title="f.stand === 'offen' ? 'Bestellt, noch nicht gebaut' : 'Gebaut'"
|
||||
:title="f.stand === 'offen' ? 'Noch nicht bewiesen — Claude prüft sie beim nächsten Lauf' : 'Gebaut und geprüft'"
|
||||
@click.stop="wechsleStand(i)"
|
||||
>{{ f.stand === 'offen' ? 'offen' : '✓' }}</button>
|
||||
<span v-else-if="f.stand === 'offen'" class="stand offen">offen</span>
|
||||
@@ -488,30 +514,40 @@ function alleAblehnen() {
|
||||
<!-- Ziel, Regeln und Testen nur im Vollmodus -->
|
||||
<div class="details">
|
||||
<!-- Ziel der Funktion -->
|
||||
<div v-if="istAktiv('ziel', i) || f.ziel" class="block">
|
||||
<div v-if="istAktiv('ziel', i) || f.ziel || hatVorschlag('ziel', f.text)" class="block">
|
||||
<p class="mini">Ziel</p>
|
||||
<ProblemHinweis :probleme="problemeFeld('ziel', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<textarea
|
||||
v-if="istAktiv('ziel', i)"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
v-wachsend
|
||||
rows="2"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else class="inhalt klickbar" v-html="md(f.ziel)" @click="starte('ziel', i)" />
|
||||
<div v-else-if="f.ziel" 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>
|
||||
<button
|
||||
v-if="!f.ziel && !istAktiv('ziel', i) && !hatVorschlag('ziel', f.text)"
|
||||
class="hinzu"
|
||||
@click="starte('ziel', i)"
|
||||
>+ Ziel</button>
|
||||
|
||||
<!-- Regeln als Punktliste, ein Block je Funktion -->
|
||||
<div v-if="istAktiv('regeln', i) || f.regeln" class="block">
|
||||
<div
|
||||
v-if="istAktiv('regeln', i) || f.regeln || hatVorschlag('regeln', f.text)"
|
||||
class="block"
|
||||
>
|
||||
<p class="mini">Regeln</p>
|
||||
<ProblemHinweis :probleme="problemeFeld('regeln', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<textarea
|
||||
v-if="istAktiv('regeln', i)"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
v-wachsend
|
||||
rows="2"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
@@ -526,17 +562,25 @@ function alleAblehnen() {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!f.regeln && !istAktiv('regeln', i)" class="hinzu" @click="starte('regeln', i)">+ Regeln</button>
|
||||
<button
|
||||
v-if="!f.regeln && !istAktiv('regeln', i) && !hatVorschlag('regeln', f.text)"
|
||||
class="hinzu"
|
||||
@click="starte('regeln', i)"
|
||||
>+ Regeln</button>
|
||||
|
||||
<!-- Testen als nummerierte Schritte -->
|
||||
<div v-if="istAktiv('testen', i) || f.testen" class="block">
|
||||
<div
|
||||
v-if="istAktiv('testen', i) || f.testen || hatVorschlag('testen', f.text)"
|
||||
class="block"
|
||||
>
|
||||
<p class="mini">Testen</p>
|
||||
<ProblemHinweis :probleme="problemeFeld('testen', f.text)" @annehmen="annehmen" @ablehnen="ablehnen" />
|
||||
<textarea
|
||||
v-if="istAktiv('testen', i)"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
v-wachsend
|
||||
rows="2"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
@@ -554,7 +598,11 @@ function alleAblehnen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="!f.testen && !istAktiv('testen', i)" class="hinzu" @click="starte('testen', i)">+ Testen</button>
|
||||
<button
|
||||
v-if="!f.testen && !istAktiv('testen', i) && !hatVorschlag('testen', f.text)"
|
||||
class="hinzu"
|
||||
@click="starte('testen', i)"
|
||||
>+ Testen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -611,6 +659,7 @@ function alleAblehnen() {
|
||||
|
||||
.karte.kern { border-color: var(--akzent); }
|
||||
.karte.optional { border-color: var(--gruen); }
|
||||
.karte.vision { border-color: var(--gold); }
|
||||
/* Prüfung: fehlerhafte Karte und Stelle rot */
|
||||
.karte.fehlerhaft { border-color: var(--rot); }
|
||||
/* Kurz hervorheben, wenn das Panel hierher springt */
|
||||
@@ -656,10 +705,19 @@ function alleAblehnen() {
|
||||
}
|
||||
.icon-knopf.uebernehmen:hover { color: var(--gruen); background: var(--gruen-bg); }
|
||||
|
||||
/* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten,
|
||||
alle Details offen */
|
||||
.karte.gross { column-span: all; display: block; }
|
||||
.karte.gross .liste { columns: 320px; column-gap: 1.2rem; }
|
||||
/* Vollmodus: Funktionen als Boxen in responsiven Spalten, alle Details offen.
|
||||
Beim Aufklappen dockt die Karte oben an — mit etwas Luft zum Rand. */
|
||||
.karte.gross { scroll-margin-top: 0.6rem; }
|
||||
/* Gitter, nicht Spalten: eine Funktionsbox ist unteilbar und hoch. Mit `columns`
|
||||
verteilte der Browser zwei Boxen auf Spur 1 und 3 und riss eine Lücke von einer
|
||||
halben Kartenbreite auf. Ein Gitter setzt sie deterministisch nebeneinander. */
|
||||
.karte.gross .liste {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
gap: 0 1.2rem;
|
||||
align-items: start;
|
||||
}
|
||||
.karte.gross .regelliste { columns: 22rem; }
|
||||
.karte.gross section.leer { display: block; }
|
||||
.karte.gross .funktion {
|
||||
break-inside: avoid;
|
||||
@@ -736,7 +794,15 @@ section.leer { display: none; }
|
||||
}
|
||||
.zeile .inhalt, .zeile input, .zeile textarea { flex: 1; min-width: 0; }
|
||||
|
||||
/* Funktionsliste */
|
||||
/* Funktionsliste und Karten-Regeln laufen in Spalten — eine lange Liste ließ
|
||||
die halbe Karte leer. Kompakt stehen nur Titel, da passen zwei Spalten immer.
|
||||
Offen entscheidet die Breite: `columns: 2` riss auf einer vollbreiten Karte
|
||||
zwei 780px-Spalten für 370px-Boxen auf. */
|
||||
.liste { columns: 2; column-gap: 0.8rem; }
|
||||
.funktion { break-inside: avoid; }
|
||||
.regelliste { columns: 2; column-gap: 0.8rem; }
|
||||
.regelliste .regel, .regelliste .hinweis-block { break-inside: avoid; }
|
||||
|
||||
.zeile.kopf { padding: 0.15rem 0.2rem; border-radius: 4px; }
|
||||
.karte.gross .zeile.kopf:hover { background: var(--code-bg); }
|
||||
.funktionstitel { flex: 1; }
|
||||
@@ -785,15 +851,19 @@ section.leer { display: none; }
|
||||
margin: 0.05rem 0;
|
||||
}
|
||||
|
||||
/* Dieselbe Schrift wie der gelesene Text: was man tippt, sieht aus wie das,
|
||||
was nachher dasteht. Kein Rollbalken, kein Ziehgriff — das Feld wächst selbst. */
|
||||
textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Karte: MMB schaltet den Modus um; Texte sind nur im Vollmodus bearbeitbar */
|
||||
/* Karte: LMB öffnet, MMB schaltet um; Texte sind nur im Vollmodus bearbeitbar */
|
||||
.karte { cursor: pointer; }
|
||||
.karte.gross { cursor: default; }
|
||||
.klickbar { user-select: none; }
|
||||
.karte.gross .klickbar, .karte.gross h3 { cursor: text; }
|
||||
.inhalt.klickbar { border-radius: 4px; }
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { fetchFeatures, featureAnlegen, featureLoeschen, featureVerschieben } from '../api.js'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
|
||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||
import { probleme, problemeFuer, erledige } from '../composables/pruefung.js'
|
||||
import { laeuft as planungLaeuft, aenderungen, sicht, fuerKarte, setzeZurueck } from '../composables/planung.js'
|
||||
import FeatureKarte from './FeatureKarte.vue'
|
||||
import VisionKarte from './VisionKarte.vue'
|
||||
import ModalBox from './ModalBox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -19,7 +18,7 @@ const emit = defineEmits(['geaendert'])
|
||||
const features = ref([])
|
||||
const meldung = ref('')
|
||||
const suche = ref('')
|
||||
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen'|'verschieben', feature}
|
||||
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', feature}
|
||||
const neuKey = ref(null) // frisch angelegte Karte: direkt im Vollmodus öffnen
|
||||
|
||||
// Stabiler Key je Karte: die id ändert sich mit dem Titel, ein Wechsel würde
|
||||
@@ -27,8 +26,13 @@ const neuKey = ref(null) // frisch angelegte Karte: direkt im Vollmodus öffnen
|
||||
let letzterKey = 0
|
||||
const mitKey = (f) => ({ ...f, key: ++letzterKey })
|
||||
|
||||
// Vision zuerst, dann kern, dann optional — innerhalb der Gruppe alphabetisch.
|
||||
// Das Wichtigste steht oben links, ohne dass jemand sortieren muss.
|
||||
const RANG = { vision: 0, kern: 1, optional: 2 }
|
||||
|
||||
function sortiere() {
|
||||
features.value.sort((a, b) =>
|
||||
(RANG[a.sektionen.art] ?? 1) - (RANG[b.sektionen.art] ?? 1) ||
|
||||
a.sektionen.titel.localeCompare(b.sektionen.titel, 'de', { sensitivity: 'base' }))
|
||||
}
|
||||
|
||||
@@ -37,6 +41,7 @@ async function laden() {
|
||||
neuKey.value = null
|
||||
try {
|
||||
features.value = (await fetchFeatures(props.projekt, sicht.value)).map(mitKey)
|
||||
sortiere()
|
||||
} catch (e) {
|
||||
features.value = []
|
||||
meldung.value = e.message
|
||||
@@ -51,8 +56,7 @@ function aktualisiert(altId, f) {
|
||||
emit('geaendert')
|
||||
}
|
||||
|
||||
// Die Vision steht über den Features, nicht zwischen ihnen — eigene Zeile, goldener
|
||||
// Rand, und höchstens eine je Projekt.
|
||||
// Höchstens eine Vision je Projekt — nur fürs Kontextmenü interessant
|
||||
const vision = computed(() => features.value.find(f => f.sektionen.art === 'vision'))
|
||||
|
||||
// Suche über alles, was auf der Karte steht — Titel, Ziel, Regeln, Funktionen.
|
||||
@@ -66,11 +70,43 @@ function passt(f) {
|
||||
return text.join(' ').toLowerCase().includes(wort)
|
||||
}
|
||||
|
||||
const karten = computed(() =>
|
||||
features.value.filter(f => f.sektionen.art !== 'vision' && passt(f)))
|
||||
const sichtbareVision = computed(() => (vision.value && passt(vision.value)) ? vision.value : null)
|
||||
const versteckt = computed(() =>
|
||||
features.value.length - karten.value.length - (sichtbareVision.value ? 1 : 0))
|
||||
const karten = computed(() => features.value.filter(passt))
|
||||
|
||||
// Eine aufgeklappte Karte nimmt die volle Breite — aber in ihrer eigenen Zeile,
|
||||
// nicht am Anfang des Gitters. Dafür wird die Zeile aus der Kompaktordnung
|
||||
// errechnet und die große Karte vor ihre Zeilennachbarn gestellt.
|
||||
const gitterEl = ref(null)
|
||||
const spalten = ref(3)
|
||||
const grosse = ref(new Set())
|
||||
|
||||
// Höchstens eine Karte offen: wer öffnet, schließt die anderen
|
||||
const offen = ref(null)
|
||||
|
||||
function setzeGross(key, g) {
|
||||
const s = new Set(grosse.value)
|
||||
g ? s.add(key) : s.delete(key)
|
||||
grosse.value = s
|
||||
if (g) offen.value = key
|
||||
else if (offen.value === key) offen.value = null
|
||||
}
|
||||
|
||||
function messeSpalten() {
|
||||
if (!gitterEl.value) return
|
||||
spalten.value = getComputedStyle(gitterEl.value).gridTemplateColumns.split(' ').length
|
||||
}
|
||||
onMounted(() => {
|
||||
messeSpalten()
|
||||
new ResizeObserver(messeSpalten).observe(gitterEl.value)
|
||||
})
|
||||
|
||||
const anzeige = computed(() => {
|
||||
const S = Math.max(1, spalten.value)
|
||||
return karten.value
|
||||
.map((f, i) => ({ f, reihe: Math.floor(i / S), gross: grosse.value.has(f.key) }))
|
||||
.sort((a, b) => a.reihe - b.reihe || b.gross - a.gross)
|
||||
.map(x => x.f)
|
||||
})
|
||||
const versteckt = computed(() => features.value.length - karten.value.length)
|
||||
|
||||
function bereichsMenue(e) {
|
||||
const eintraege = [
|
||||
@@ -125,7 +161,15 @@ async function entfernen() {
|
||||
|
||||
// Eine vorgeschlagene Karte gibt es noch nicht — sie kann an keiner Karte hängen.
|
||||
// Darum steht sie als eigener Umriss im Gitter, mit demselben ✓ und ✕.
|
||||
const vorschlaege = computed(() => probleme.value.filter(p => p.feld === 'neue_karte'))
|
||||
const vorschlaege = computed(() =>
|
||||
nurLesen.value ? [] : probleme.value.filter(p => p.feld === 'neue_karte'))
|
||||
|
||||
const punkte = (text) => (text || '').split('\n').map(z => z.replace(/^-\s*/, '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
function badgeFarbe(art) {
|
||||
return art === 'vision' ? 'gold' : art === 'kern' ? 'blau' : 'gruen'
|
||||
}
|
||||
|
||||
async function karteAnnehmen(p) {
|
||||
try {
|
||||
@@ -159,22 +203,6 @@ async function zurueck(titel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Andere Projekte als Ziel; ins eigene zu verschieben ergäbe keinen Sinn
|
||||
const andereProjekte = computed(() =>
|
||||
props.projekte.map(p => p.name).filter(n => n !== props.projekt))
|
||||
|
||||
async function verschieben(ziel) {
|
||||
const id = modal.value.feature.id
|
||||
try {
|
||||
await featureVerschieben(props.projekt, id, ziel)
|
||||
features.value = features.value.filter(f => f.id !== id)
|
||||
modal.value = null
|
||||
emit('geaendert')
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.projekt, laden, { immediate: true })
|
||||
watch(() => props.stand, laden)
|
||||
// Der Reiter entscheidet, welchen Ordner der Server liest — also neu laden.
|
||||
@@ -204,38 +232,56 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div class="gitter">
|
||||
<div ref="gitterEl" class="gitter">
|
||||
<article v-for="p in vorschlaege" :key="p.vorschlag" class="karte umriss">
|
||||
<header>
|
||||
<h3>{{ p.vorschlag }}</h3>
|
||||
<span v-if="p.sektionen" class="badge" :class="badgeFarbe(p.sektionen.art)">
|
||||
{{ p.sektionen.art }}
|
||||
</span>
|
||||
<button class="icon-knopf uebernehmen" title="Karte anlegen" @click="karteAnnehmen(p)">✓</button>
|
||||
<button class="icon-knopf rot" title="Vorschlag verwerfen" @click="erledige(p)">✕</button>
|
||||
</header>
|
||||
<p class="hinweis">Vorgeschlagene Karte</p>
|
||||
<!-- Der ganze Inhalt, nicht nur der Titel: ob eine Karte taugt, entscheidet
|
||||
sich an ihren Funktionen. -->
|
||||
<template v-if="p.sektionen">
|
||||
<section v-if="p.sektionen.ziel">
|
||||
<p class="gruppe">Ziel</p>
|
||||
<p class="inhalt">{{ p.sektionen.ziel }}</p>
|
||||
</section>
|
||||
<section v-if="punkte(p.sektionen.regeln).length">
|
||||
<p class="gruppe">Regeln</p>
|
||||
<div v-for="(r, k) in punkte(p.sektionen.regeln)" :key="k" class="regel">{{ r }}</div>
|
||||
</section>
|
||||
<section v-if="p.sektionen.funktionen.length">
|
||||
<p class="gruppe">Funktionen</p>
|
||||
<div v-for="(f, k) in p.sektionen.funktionen" :key="k" class="funktion">
|
||||
<p class="ftitel">{{ f.text }}</p>
|
||||
<p v-if="f.ziel" class="inhalt">{{ f.ziel }}</p>
|
||||
<div v-for="(r, j) in punkte(f.regeln)" :key="j" class="regel">{{ r }}</div>
|
||||
<ol v-if="punkte(f.testen).length" class="testen">
|
||||
<li v-for="(t, j) in punkte(f.testen)" :key="j">{{ t }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</article>
|
||||
|
||||
<VisionKarte
|
||||
v-if="sichtbareVision"
|
||||
:key="sichtbareVision.key"
|
||||
:projekt="projekt"
|
||||
:feature="sichtbareVision"
|
||||
@aktualisiert="aktualisiert"
|
||||
@entfernen="oeffneModal({ art: 'entfernen', feature: sichtbareVision })"
|
||||
/>
|
||||
<FeatureKarte
|
||||
v-for="f in karten"
|
||||
v-for="f in anzeige"
|
||||
:key="f.key"
|
||||
:projekt="projekt"
|
||||
:feature="f"
|
||||
:vollmodus="f.key === neuKey"
|
||||
:probleme="problemeFuer(f.id)"
|
||||
:verschiebbar="andereProjekte.length > 0"
|
||||
:probleme="nurLesen ? [] : problemeFuer(f.id)"
|
||||
:planung="fuerKarte(f.sektionen.titel)"
|
||||
:nur-lesen="nurLesen"
|
||||
@aktualisiert="aktualisiert"
|
||||
@entfernen="oeffneModal({ art: 'entfernen', feature: f })"
|
||||
@verschieben="oeffneModal({ art: 'verschieben', feature: f })"
|
||||
:zuklappen="offen !== null && offen !== f.key"
|
||||
@zuruecksetzen="zurueck(f.sektionen.titel)"
|
||||
@modus="setzeGross(f.key, $event)"
|
||||
/>
|
||||
|
||||
<!-- Weggeplante Karten stehen in keiner Datei mehr. Ohne eigenen Umriss
|
||||
@@ -248,7 +294,7 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
|
||||
<p class="hinweis">Wird gestrichen</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="!karten.length && !sichtbareVision && !meldung" class="leer">
|
||||
<p v-if="!karten.length && !meldung" class="leer">
|
||||
{{ suche.trim() ? 'Nichts gefunden.' : 'Noch keine Features. Rechtsklick zum Anlegen.' }}
|
||||
</p>
|
||||
|
||||
@@ -260,15 +306,6 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
|
||||
@bestaetigt="erstellen"
|
||||
@abbrechen="modal = null"
|
||||
/>
|
||||
<ModalBox
|
||||
v-else-if="modal && modal.art === 'verschieben'"
|
||||
titel="Feature verschieben"
|
||||
:auswahl="andereProjekte"
|
||||
knopf="Verschieben"
|
||||
:meldung="meldung"
|
||||
@bestaetigt="verschieben"
|
||||
@abbrechen="modal = null"
|
||||
/>
|
||||
<ModalBox
|
||||
v-else-if="modal"
|
||||
:titel="`${modal.feature.sektionen.titel === 'Vision' ? 'Vision' : 'Feature'} entfernen`"
|
||||
@@ -282,7 +319,7 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bereich { min-height: 100%; }
|
||||
.bereich { min-height: 100%; container-type: inline-size; }
|
||||
.kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -320,23 +357,64 @@ const nurLesen = computed(() => planungLaeuft.value && sicht.value === 'uebersic
|
||||
.umriss header { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.umriss h3 { flex: 1; margin: 0; font-size: 0.95rem; }
|
||||
.umriss .hinweis { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--dezent); }
|
||||
/* Inhalt des Vorschlags — dieselbe Gliederung wie auf einer echten Karte */
|
||||
.umriss section { margin-top: 0.55rem; }
|
||||
.umriss .gruppe {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--akzent);
|
||||
margin: 0 0 0.15rem;
|
||||
}
|
||||
.umriss .inhalt { margin: 0; font-size: 0.9rem; }
|
||||
.umriss .regel {
|
||||
position: relative;
|
||||
padding-left: 1.15rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.umriss .regel::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0.35rem;
|
||||
color: var(--dezent);
|
||||
}
|
||||
.umriss .funktion {
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.6rem 0.45rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.umriss .ftitel { margin: 0 0 0.1rem; font-weight: 600; font-size: 0.9rem; }
|
||||
.umriss .testen {
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.3rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--dezent);
|
||||
}
|
||||
.uebernehmen:hover { color: var(--gruen); background: var(--gruen-bg); }
|
||||
/* Spaltenlayout: unterschiedlich hohe Karten stapeln sich ohne Lücken.
|
||||
Die Breite ist gedeckelt — auf sehr breiten Schirmen klumpten die Karten sonst
|
||||
links und rechts blieb eine tote Fläche. */
|
||||
/* Raster: gleiche Spuren, ruhige Reihen. Die Breite ist gedeckelt — auf sehr
|
||||
breiten Schirmen klumpten die Karten sonst links und rechts blieb tote Fläche.
|
||||
`dense` füllt die Lücke, die eine doppelt breite Karte hinterlässt. */
|
||||
.gitter {
|
||||
columns: 22rem;
|
||||
column-gap: 0.9rem;
|
||||
display: grid;
|
||||
/* Drei Spuren: breit genug zum Lesen, schmal genug für den Überblick.
|
||||
Karten füllen ihre Reihe — gleiche Höhe je Zeile statt ausgefranster Kanten. */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
grid-auto-flow: dense;
|
||||
max-width: 100rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.gitter > * {
|
||||
/* inline-block macht die Karte atomar — Firefox fragmentiert sie sonst
|
||||
trotz break-inside: avoid mitten im Inhalt */
|
||||
break-inside: avoid;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-bottom: 0.9rem;
|
||||
width: 100%;
|
||||
.gitter > * { min-width: 0; }
|
||||
/* Vollmodus: volle Breite in der eigenen Zeile. Wo die Zeile beginnt, regelt
|
||||
die Liste über die Reihenfolge — CSS kennt die Zeilen nicht. */
|
||||
.gitter > :deep(.karte.gross) { grid-column: 1 / -1; }
|
||||
/* Wird der Platz eng, fallen die Spuren */
|
||||
@container (max-width: 66rem) {
|
||||
.gitter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@container (max-width: 44rem) {
|
||||
.gitter { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
101
frontend/src/components/OrdnerWahl.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { fetchOrdner } from '../api.js'
|
||||
|
||||
// Ein Verzeichnis auswählen, ohne den Pfad zu kennen: durchblättern statt tippen.
|
||||
// Das Feld bleibt trotzdem — wer den Pfad hat, fügt ihn ein und drückt Enter.
|
||||
|
||||
const props = defineProps({
|
||||
vorgabe: { type: String, default: '' },
|
||||
meldung: { type: String, default: '' }, // Fehler des Aufrufers (z. B. beim Speichern)
|
||||
})
|
||||
const emit = defineEmits(['bestaetigt', 'abbrechen'])
|
||||
|
||||
const pfad = ref('')
|
||||
const ordner = ref([])
|
||||
const eltern = ref('')
|
||||
const fehler = ref('')
|
||||
|
||||
async function lade(ziel) {
|
||||
fehler.value = ''
|
||||
try {
|
||||
const antwort = await fetchOrdner(ziel)
|
||||
pfad.value = antwort.pfad
|
||||
ordner.value = antwort.ordner
|
||||
eltern.value = antwort.eltern
|
||||
} catch (e) {
|
||||
fehler.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function beiTaste(e) {
|
||||
if (e.key === 'Escape') emit('abbrechen')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lade(props.vorgabe)
|
||||
window.addEventListener('keydown', beiTaste)
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('keydown', beiTaste))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schleier" @click.self="emit('abbrechen')">
|
||||
<div class="modal karte wahl">
|
||||
<h2>Verzeichnis festlegen</h2>
|
||||
<input
|
||||
v-model="pfad"
|
||||
placeholder="/pfad/zum/projekt"
|
||||
@keyup.enter="lade(pfad)"
|
||||
>
|
||||
<div class="liste">
|
||||
<button v-if="eltern" class="eintrag hoch" @click="lade(eltern)">↑ übergeordnet</button>
|
||||
<button
|
||||
v-for="o in ordner"
|
||||
:key="o"
|
||||
class="eintrag"
|
||||
@click="lade(`${pfad}/${o}`)"
|
||||
>▸ {{ o }}</button>
|
||||
<p v-if="!ordner.length" class="leer">Keine Unterordner</p>
|
||||
</div>
|
||||
<p v-if="fehler || meldung" class="meldung">{{ fehler || meldung }}</p>
|
||||
<footer>
|
||||
<button @click="emit('bestaetigt', '')">Zurücksetzen</button>
|
||||
<span class="platz" />
|
||||
<button @click="emit('abbrechen')">Abbrechen</button>
|
||||
<button class="primaer" :disabled="!pfad" @click="emit('bestaetigt', pfad)">
|
||||
Übernehmen
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wahl { width: min(34rem, 92vw); }
|
||||
.liste {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
.eintrag {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.eintrag:hover { background: var(--bg); }
|
||||
.hoch { color: var(--dezent); }
|
||||
.leer { margin: 0.3rem 0.5rem; color: var(--dezent); font-size: 0.85rem; }
|
||||
footer { display: flex; gap: 0.5rem; }
|
||||
.platz { flex: 1; }
|
||||
</style>
|
||||
339
frontend/src/components/RecherchePanel.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import {
|
||||
fetchRecherche, rechercheAnlegen, rechercheFragen, rechercheSchliessen,
|
||||
} from '../api.js'
|
||||
import { starteUhr, stoppeUhr } from '../composables/uhr.js'
|
||||
import { lies, setze } from '../composables/entwuerfe.js'
|
||||
import ModalBox from './ModalBox.vue'
|
||||
|
||||
// Ein Faden ist ein Thema. Mehrere nebeneinander, weil eine Suche nach „Upload zu S3"
|
||||
// nichts mit einer nach „Diagrammbibliothek" zu tun hat — im selben Verlauf zöge das
|
||||
// Modell den einen Kontext in die andere Antwort.
|
||||
//
|
||||
// Fäden laufen unabhängig: wer eine Frage abschickt und den Reiter wechselt, wartet
|
||||
// weiter. Deshalb hängt alles Laufende an der Faden-Id, nicht an der Ansicht.
|
||||
|
||||
const props = defineProps({
|
||||
projekt: { type: String, required: true },
|
||||
})
|
||||
|
||||
const faeden = ref([])
|
||||
const aktiv = ref('')
|
||||
const meldung = ref('')
|
||||
const liste = ref(null)
|
||||
const feld = ref(null)
|
||||
|
||||
// Je Faden ein eigener Startzeitpunkt — mehrere können gleichzeitig suchen.
|
||||
const seit = ref({})
|
||||
const jetzt = ref(Date.now())
|
||||
|
||||
const UHR = '__planer2RechercheUhr'
|
||||
|
||||
const faden = computed(() => faeden.value.find(f => f.id === aktiv.value) || null)
|
||||
const laeuft = computed(() => aktiv.value in seit.value)
|
||||
// Der Entwurf liegt außerhalb der Komponente: sie verschwindet beim Reiterwechsel,
|
||||
// der halb getippte Satz soll es nicht.
|
||||
const schluessel = computed(() => `recherche:${props.projekt}:${aktiv.value}`)
|
||||
const entwurf = computed({
|
||||
get: () => lies(schluessel.value),
|
||||
set: (t) => setze(schluessel.value, t),
|
||||
})
|
||||
const sekunden = computed(() =>
|
||||
Math.floor((jetzt.value - (seit.value[aktiv.value] || jetzt.value)) / 1000))
|
||||
|
||||
const md = (t) => marked.parse(t)
|
||||
|
||||
// Die Uhr läuft nur, solange irgendwo gewartet wird
|
||||
watch(() => Object.keys(seit.value).length, (n) => {
|
||||
if (n) starteUhr(UHR, () => (jetzt.value = Date.now()), 1000)
|
||||
else stoppeUhr(UHR)
|
||||
})
|
||||
|
||||
async function nachUnten() {
|
||||
await nextTick()
|
||||
if (liste.value) liste.value.scrollTop = liste.value.scrollHeight
|
||||
}
|
||||
|
||||
async function laden() {
|
||||
meldung.value = ''
|
||||
seit.value = {}
|
||||
try {
|
||||
faeden.value = (await fetchRecherche(props.projekt)).faeden
|
||||
} catch (e) {
|
||||
faeden.value = []
|
||||
meldung.value = e.message
|
||||
}
|
||||
if (!faeden.value.some(f => f.id === aktiv.value)) {
|
||||
aktiv.value = faeden.value[0]?.id || ''
|
||||
}
|
||||
nachUnten()
|
||||
}
|
||||
|
||||
async function neuerFaden() {
|
||||
meldung.value = ''
|
||||
try {
|
||||
const neu = await rechercheAnlegen(props.projekt)
|
||||
faeden.value = [...faeden.value, neu]
|
||||
aktiv.value = neu.id
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
feld.value?.focus()
|
||||
}
|
||||
|
||||
async function senden() {
|
||||
const id = aktiv.value
|
||||
const text = entwurf.value.trim()
|
||||
if (!text || !id || id in seit.value) return
|
||||
meldung.value = ''
|
||||
seit.value = { ...seit.value, [id]: Date.now() }
|
||||
jetzt.value = Date.now()
|
||||
// Die eigene Zeile sofort zeigen; auf die Antwort wartet man sonst vor leerem Feld
|
||||
faeden.value = faeden.value.map(f => f.id === id
|
||||
? { ...f, verlauf: [...f.verlauf, { rolle: 'person', text }] } : f)
|
||||
setze(`recherche:${props.projekt}:${id}`, '')
|
||||
nachUnten()
|
||||
try {
|
||||
const antwort = await rechercheFragen(props.projekt, id, text)
|
||||
faeden.value = faeden.value.map(f => (f.id === id ? antwort : f))
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
// Nichts verlieren: der Entwurf kommt zurück, die vorgezogene Zeile weg
|
||||
setze(`recherche:${props.projekt}:${id}`, text)
|
||||
faeden.value = faeden.value.map(f => f.id === id
|
||||
? { ...f, verlauf: f.verlauf.slice(0, -1) } : f)
|
||||
}
|
||||
const { [id]: _weg, ...rest } = seit.value
|
||||
seit.value = rest
|
||||
if (aktiv.value === id) {
|
||||
nachUnten()
|
||||
feld.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Ein leerer Faden verschwindet ohne Rückfrage — es geht nichts verloren.
|
||||
const frage = ref(null)
|
||||
function schliessen(f) {
|
||||
if (f.verlauf.length) frage.value = f
|
||||
else entferne(f.id)
|
||||
}
|
||||
|
||||
async function entferne(id) {
|
||||
frage.value = null
|
||||
try {
|
||||
await rechercheSchliessen(props.projekt, id)
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
return
|
||||
}
|
||||
faeden.value = faeden.value.filter(f => f.id !== id)
|
||||
setze(`recherche:${props.projekt}:${id}`, '')
|
||||
if (aktiv.value === id) aktiv.value = faeden.value[0]?.id || ''
|
||||
}
|
||||
|
||||
watch(() => props.projekt, laden, { immediate: true })
|
||||
watch(aktiv, nachUnten)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="recherche">
|
||||
<header>
|
||||
<p class="gruppe">Recherche</p>
|
||||
<button class="icon-knopf" title="Neuen Faden öffnen" @click="neuerFaden">+</button>
|
||||
</header>
|
||||
|
||||
<div v-if="faeden.length" class="reiter">
|
||||
<button
|
||||
v-for="f in faeden"
|
||||
:key="f.id"
|
||||
class="reiter-knopf"
|
||||
:class="{ an: f.id === aktiv }"
|
||||
:title="f.titel"
|
||||
@click="aktiv = f.id"
|
||||
>
|
||||
<span v-if="f.id in seit" class="puls" />
|
||||
<span class="name">{{ f.titel }}</span>
|
||||
<span class="zu" title="Faden schließen" @click.stop="schliessen(f)">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
|
||||
<div v-if="faden" ref="liste" class="verlauf">
|
||||
<p v-if="!faden.verlauf.length" class="leer">
|
||||
Frag nach, was du für die Planung wissen musst. Die Antwort kommt mit Quellen.
|
||||
</p>
|
||||
<div v-for="(e, i) in faden.verlauf" :key="i" class="zeile" :class="e.rolle">
|
||||
<div v-if="e.rolle === 'ki'" class="inhalt" v-html="md(e.text)" />
|
||||
<div v-else class="inhalt">{{ e.text }}</div>
|
||||
</div>
|
||||
<p v-if="laeuft" class="denkt">sucht … {{ sekunden }} s</p>
|
||||
</div>
|
||||
<p v-else class="leer">Kein Faden offen. Mit + einen aufmachen.</p>
|
||||
|
||||
<div v-if="faden" class="eingabe">
|
||||
<!-- Tippen bleibt möglich, während die Antwort läuft — nur Senden wartet -->
|
||||
<textarea
|
||||
ref="feld"
|
||||
v-model="entwurf"
|
||||
rows="3"
|
||||
placeholder="Was willst du wissen?"
|
||||
@keydown.enter.exact.prevent="senden"
|
||||
/>
|
||||
<button class="primaer" :disabled="!entwurf.trim() || laeuft" @click="senden">
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ModalBox
|
||||
v-if="frage"
|
||||
titel="Faden schließen"
|
||||
:frage="`„${frage.titel}“ wird mit allem Gefundenen gelöscht. Schließen?`"
|
||||
knopf="Schließen"
|
||||
@bestaetigt="entferne(frage.id)"
|
||||
@abbrechen="frage = null"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recherche {
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--rand);
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
min-height: 0; /* sonst wächst das Panel über die Zeile hinaus */
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.gruppe {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dezent);
|
||||
}
|
||||
|
||||
/* Reiter wie im Browser: der aktive sitzt vorn, die anderen treten zurück.
|
||||
Sie umbrechen, statt zu scrollen — auf 20rem passen sonst zwei. */
|
||||
.reiter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--rand);
|
||||
padding-bottom: 0.4rem;
|
||||
flex: none;
|
||||
}
|
||||
.reiter-knopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 0.4rem;
|
||||
background: none;
|
||||
color: var(--dezent);
|
||||
padding: 0.15rem 0.4rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.reiter-knopf.an {
|
||||
color: var(--akzent-text);
|
||||
background: var(--akzent);
|
||||
border-color: var(--akzent);
|
||||
}
|
||||
.reiter-knopf .name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.reiter-knopf .zu { opacity: 0.55; flex: none; }
|
||||
.reiter-knopf .zu:hover { opacity: 1; }
|
||||
|
||||
/* Eigener Scrollbereich: die Eingabe bleibt immer unten sichtbar */
|
||||
.verlauf {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.leer { color: var(--dezent); font-size: 0.85rem; margin: 0; }
|
||||
.zeile { display: flex; }
|
||||
.zeile.person { justify-content: flex-end; }
|
||||
.inhalt {
|
||||
max-width: 92%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.person .inhalt { background: var(--akzent); color: var(--akzent-text); }
|
||||
.ki .inhalt { background: var(--bg); white-space: normal; }
|
||||
.ki .inhalt :deep(p) { margin: 0.3rem 0; }
|
||||
.ki .inhalt :deep(p:first-child) { margin-top: 0; }
|
||||
/* Die Antwort trägt ihre Struktur selbst: „Festgelegt", „Offen", „Quellen".
|
||||
Klein und in Versalien, damit sie im schmalen Panel gliedert statt zu dröhnen. */
|
||||
.ki .inhalt :deep(h2), .ki .inhalt :deep(h3) {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dezent);
|
||||
margin: 0.7rem 0 0.25rem;
|
||||
}
|
||||
.ki .inhalt :deep(ul), .ki .inhalt :deep(ol) {
|
||||
margin: 0.25rem 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.ki .inhalt :deep(li) { margin: 0.12rem 0; }
|
||||
/* Quellen sind der Zweck der Recherche — Links müssen erkennbar und umbrechbar sein */
|
||||
.ki .inhalt :deep(a) { color: var(--akzent); overflow-wrap: anywhere; }
|
||||
.ki .inhalt :deep(code) { font-size: 0.92em; overflow-wrap: anywhere; }
|
||||
/* Vergleichstabellen laufen im schmalen Panel sonst über den Rand */
|
||||
.ki .inhalt :deep(table) {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.94em;
|
||||
}
|
||||
.ki .inhalt :deep(th), .ki .inhalt :deep(td) {
|
||||
border: 1px solid var(--rand);
|
||||
padding: 0.15rem 0.35rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.denkt { color: var(--dezent); font-size: 0.82rem; margin: 0; }
|
||||
.eingabe { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
textarea { width: 100%; resize: vertical; font-size: 0.86rem; }
|
||||
|
||||
/* Ein laufender Faden blinkt im Reiter — auch wenn man woanders liest */
|
||||
.puls {
|
||||
display: inline-block;
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: puls 1.2s ease-in-out infinite;
|
||||
flex: none;
|
||||
}
|
||||
@keyframes puls {
|
||||
0%, 100% { opacity: 0.25; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { projektAnlegen, projektUmbenennen, projektLoeschen } from '../api.js'
|
||||
import { projektAnlegen, projektUmbenennen, projektLoeschen,
|
||||
fetchVerzeichnis, verzeichnisSetzen } from '../api.js'
|
||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||
import ModalBox from './ModalBox.vue'
|
||||
import OrdnerWahl from './OrdnerWahl.vue'
|
||||
|
||||
defineProps({
|
||||
projekte: { type: Array, required: true },
|
||||
@@ -14,7 +16,7 @@ const meldung = ref('')
|
||||
const bearbeitet = ref(null) // Projektname, der gerade umbenannt wird
|
||||
const umbenennung = ref('')
|
||||
const umbenennFeld = ref(null)
|
||||
const modal = ref(null) // {art: 'erstellen'|'entfernen', name?}
|
||||
const modal = ref(null) // {art: 'erstellen'|'entfernen'|'verzeichnis', name?, pfad?}
|
||||
|
||||
async function starteUmbenennen(name) {
|
||||
meldung.value = ''
|
||||
@@ -40,10 +42,32 @@ async function umbenennen(alt) {
|
||||
function projektMenue(e, name) {
|
||||
oeffneMenue(e, [
|
||||
{ text: '✎ Projekt anpassen', aktion: () => starteUmbenennen(name) },
|
||||
{ text: '▦ Verzeichnis festlegen', aktion: () => starteVerzeichnis(name) },
|
||||
{ text: '✕ Projekt entfernen', aktion: () => oeffneModal({ art: 'entfernen', name }) },
|
||||
])
|
||||
}
|
||||
|
||||
// Wo der Agent die Planung implementiert. Ohne Verzeichnis wird nur umgesetzt.
|
||||
async function starteVerzeichnis(name) {
|
||||
meldung.value = ''
|
||||
let pfad = ''
|
||||
try {
|
||||
pfad = (await fetchVerzeichnis(name)).pfad
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
modal.value = { art: 'verzeichnis', name, pfad }
|
||||
}
|
||||
|
||||
async function setzeVerzeichnis(pfad) {
|
||||
try {
|
||||
await verzeichnisSetzen(modal.value.name, pfad)
|
||||
modal.value = null
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function leistenMenue(e) {
|
||||
oeffneMenue(e, [{ text: '+ Projekt erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) }])
|
||||
}
|
||||
@@ -107,6 +131,13 @@ async function entfernen() {
|
||||
@bestaetigt="erstellen"
|
||||
@abbrechen="modal = null"
|
||||
/>
|
||||
<OrdnerWahl
|
||||
v-else-if="modal && modal.art === 'verzeichnis'"
|
||||
:vorgabe="modal.pfad"
|
||||
:meldung="meldung"
|
||||
@bestaetigt="setzeVerzeichnis"
|
||||
@abbrechen="modal = null"
|
||||
/>
|
||||
<ModalBox
|
||||
v-else-if="modal"
|
||||
titel="Projekt entfernen"
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { featureSpeichern } from '../api.js'
|
||||
import { oeffneMenue } from '../composables/kontextmenue.js'
|
||||
|
||||
// Die Vision hält fest, wie es gedacht ist. Sie ist die Vorlage, nicht der
|
||||
// Prüfling — deshalb hat sie weder Badge noch Funktionen noch Problemhinweise.
|
||||
// Eine eigene Karte statt eines Sonderwegs in FeatureKarte: dort steckt die ganze
|
||||
// Prüfmechanik, von der hier nichts gilt.
|
||||
//
|
||||
// Die beiden Modi hat sie wie jede Karte: Kompaktmodus zum Lesen, Vollmodus zum
|
||||
// Anpassen. Kompakt bleiben nur die obersten Punkte stehen — eine Vision ist
|
||||
// verschachtelt, und ausgeklappt drängt sie alles andere aus dem Bild.
|
||||
|
||||
const props = defineProps({
|
||||
projekt: { type: String, required: true },
|
||||
feature: { type: Object, required: true },
|
||||
})
|
||||
const emit = defineEmits(['aktualisiert', 'entfernen'])
|
||||
|
||||
const TITEL = 'Vision'
|
||||
const TRENNER = '-'.repeat(40)
|
||||
|
||||
const text = ref(props.feature.sektionen.ziel)
|
||||
watch(() => props.feature, f => { text.value = f.sektionen.ziel })
|
||||
|
||||
const gross = ref(false)
|
||||
const aktiv = ref(false)
|
||||
const entwurf = ref('')
|
||||
const meldung = ref('')
|
||||
const feldRef = ref(null)
|
||||
|
||||
// Lokale, selbst geschriebene Daten — kein Sanitizing nötig
|
||||
const md = (t) => marked.parse(t)
|
||||
|
||||
async function starte() {
|
||||
if (!gross.value) return // Bearbeiten nur in der ausführlichen Ansicht
|
||||
entwurf.value = text.value
|
||||
aktiv.value = true
|
||||
await nextTick()
|
||||
feldRef.value?.focus()
|
||||
}
|
||||
|
||||
function abbrechen() {
|
||||
aktiv.value = false
|
||||
}
|
||||
|
||||
async function fertig() {
|
||||
if (!aktiv.value) return
|
||||
aktiv.value = false
|
||||
const neu = entwurf.value.trim()
|
||||
if (neu === text.value) return
|
||||
text.value = neu
|
||||
await speichern()
|
||||
}
|
||||
|
||||
// Kompaktmodus zum Lesen, Vollmodus zum Anpassen — MMB auf die Karte
|
||||
// oder das Augen-Icon schaltet um
|
||||
function wechsleAnsicht() {
|
||||
gross.value = !gross.value
|
||||
if (!gross.value) aktiv.value = false
|
||||
}
|
||||
|
||||
function kartenMenue(e) {
|
||||
oeffneMenue(e, [{ text: '✕ Vision entfernen', aktion: () => emit('entfernen') }])
|
||||
}
|
||||
|
||||
function baueRoh() {
|
||||
return [
|
||||
`<!-- titel -->\n${TITEL}`,
|
||||
'<!-- art -->\nvision',
|
||||
`<!-- ziel -->\n${text.value}`,
|
||||
].join(`\n${TRENNER}\n`) + '\n'
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
meldung.value = ''
|
||||
try {
|
||||
emit('aktualisiert', props.feature.id,
|
||||
await featureSpeichern(props.projekt, props.feature.id, baueRoh()))
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function zeilen(t) {
|
||||
return Math.max(6, t.split('\n').length + 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="karte vision"
|
||||
:class="{ gross }"
|
||||
@mousedown.middle.prevent
|
||||
@auxclick.middle.prevent="wechsleAnsicht"
|
||||
@contextmenu.prevent.stop="kartenMenue"
|
||||
>
|
||||
<header>
|
||||
<h3>{{ TITEL }}</h3>
|
||||
<button
|
||||
class="icon-knopf"
|
||||
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
|
||||
@click="wechsleAnsicht"
|
||||
>👁</button>
|
||||
</header>
|
||||
|
||||
<textarea
|
||||
v-if="aktiv"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div
|
||||
v-else-if="text"
|
||||
class="inhalt"
|
||||
:class="{ klickbar: gross }"
|
||||
:title="gross ? 'Klicken zum Bearbeiten' : null"
|
||||
v-html="md(text)"
|
||||
@click="starte"
|
||||
/>
|
||||
<p v-else-if="gross" class="hinweis" @click="starte">Wie soll es werden?</p>
|
||||
<p v-else class="hinweis">Noch nichts festgehalten.</p>
|
||||
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Goldener Rand. Kompakt eine Karte wie jede andere, im Vollmodus über alle
|
||||
Spalten — sonst würde der ausgeklappte Text die schmale Spalte sprengen. */
|
||||
.vision { border: 2px solid var(--gold); cursor: pointer; }
|
||||
.vision.gross { cursor: default; column-span: all; display: block; }
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
user-select: none;
|
||||
}
|
||||
.klickbar { cursor: text; border-radius: 4px; }
|
||||
.klickbar:hover { background: var(--gold-bg); }
|
||||
.hinweis {
|
||||
margin: 0;
|
||||
color: var(--dezent);
|
||||
user-select: none;
|
||||
}
|
||||
.gross .hinweis { cursor: text; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border-color: var(--gold);
|
||||
}
|
||||
.inhalt :deep(p) { margin: 0.2rem 0; }
|
||||
.inhalt :deep(ul), .inhalt :deep(ol) { margin: 0.2rem 0; padding-left: 1.2rem; }
|
||||
.inhalt :deep(li) { margin: 0.1rem 0; }
|
||||
.inhalt :deep(h1), .inhalt :deep(h2), .inhalt :deep(h3) {
|
||||
font-size: 0.95rem;
|
||||
margin: 0.6rem 0 0.3rem;
|
||||
}
|
||||
/* Kompakt: nur die oberste Ebene. Die Verschachtelung ist die Ausarbeitung —
|
||||
sie gehört in den Vollmodus, sonst füllt die Vision den halben Schirm. */
|
||||
.vision:not(.gross) .inhalt {
|
||||
user-select: none;
|
||||
max-height: 11rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vision:not(.gross) .inhalt :deep(ul ul),
|
||||
.vision:not(.gross) .inhalt :deep(ul ol),
|
||||
.vision:not(.gross) .inhalt :deep(ol ul),
|
||||
.vision:not(.gross) .inhalt :deep(ol ol) { display: none; }
|
||||
.meldung { margin: 0.4rem 0 0; }
|
||||
</style>
|
||||
24
frontend/src/composables/entwuerfe.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// Halb getippte Nachrichten überleben den Reiterwechsel.
|
||||
//
|
||||
// Panels werden beim Wechsel abgeräumt und neu gebaut; ein `ref` in der Komponente
|
||||
// stirbt mit ihr. Wer mitten im Satz nachschauen wollte, was auf einer Karte steht,
|
||||
// fand danach ein leeres Feld vor. Also liegt der Text außerhalb der Komponente.
|
||||
//
|
||||
// Der Schlüssel nennt Ort und Gegenstand — `chat:MeinProjekt`,
|
||||
// `recherche:MeinProjekt:a1b2c3d4`. Zwei Fäden teilen sich so keinen Entwurf, und
|
||||
// zwei Projekte auch nicht.
|
||||
//
|
||||
// Nur im Speicher: ein Entwurf gilt für diese Sitzung. Was bleiben soll, wird
|
||||
// abgeschickt.
|
||||
const texte = reactive({})
|
||||
|
||||
export function lies(schluessel) {
|
||||
return texte[schluessel] || ''
|
||||
}
|
||||
|
||||
export function setze(schluessel, text) {
|
||||
if (text) texte[schluessel] = text
|
||||
else delete texte[schluessel]
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { starteUhr, stoppeUhr } from './uhr.js'
|
||||
import {
|
||||
fetchPlanung, planungStarten, planungUmsetzen, planungAbbrechen,
|
||||
planungZuruecksetzen,
|
||||
planungZuruecksetzen, fetchAuftrag,
|
||||
} from '../api.js'
|
||||
|
||||
// Zwei Sichten auf dieselben Karten. „Übersicht" sagt, was das Projekt kann.
|
||||
@@ -36,13 +37,8 @@ function uebernehmen(stand) {
|
||||
else if (!vorher) sicht.value = 'planung'
|
||||
}
|
||||
|
||||
export async function lade(projekt) {
|
||||
projektName = projekt
|
||||
meldung.value = ''
|
||||
if (!projekt) {
|
||||
uebernehmen({})
|
||||
return
|
||||
}
|
||||
// Nur den Planungsstand nachlesen — der Agentenzustand bleibt unangetastet.
|
||||
async function ladeStand(projekt) {
|
||||
try {
|
||||
uebernehmen(await fetchPlanung(projekt))
|
||||
} catch (e) {
|
||||
@@ -50,6 +46,27 @@ export async function lade(projekt) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function lade(projekt) {
|
||||
projektName = projekt
|
||||
meldung.value = ''
|
||||
// Agentenzustand gehört zum Projekt — beim Wechsel neu erfragen
|
||||
stoppeUhr(UHR)
|
||||
agentLaeuft.value = false
|
||||
agentMeldung.value = ''
|
||||
agentBericht.value = ''
|
||||
agentSchritte.value = []
|
||||
agentAktivitaet.value = ''
|
||||
agentUebernommen.value = false
|
||||
if (!projekt) {
|
||||
uebernehmen({})
|
||||
return
|
||||
}
|
||||
await ladeStand(projekt)
|
||||
try {
|
||||
if ((await fetchAuftrag(projekt)).laeuft) beobachteAgent()
|
||||
} catch { /* ohne Antwort läuft eben keiner */ }
|
||||
}
|
||||
|
||||
export async function starte() {
|
||||
if (!projektName) return false
|
||||
meldung.value = ''
|
||||
@@ -69,7 +86,8 @@ export async function umsetzen() {
|
||||
if (!projektName) return false
|
||||
meldung.value = ''
|
||||
try {
|
||||
await planungUmsetzen(projektName)
|
||||
const antwort = await planungUmsetzen(projektName)
|
||||
if (antwort.agent) beobachteAgent()
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
return false
|
||||
@@ -78,6 +96,50 @@ export async function umsetzen() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Mit Arbeitsverzeichnis implementiert Claude die umgesetzte Planung im Code.
|
||||
// Der Lauf gehört zum Projekt; die Anzeige fragt nach, bis er fertig ist.
|
||||
export const agentLaeuft = ref(false)
|
||||
export const agentMeldung = ref('')
|
||||
export const agentBericht = ref('')
|
||||
export const agentSchritte = ref([])
|
||||
export const agentAktivitaet = ref('')
|
||||
export const agentUebernommen = ref(false)
|
||||
|
||||
const UHR = '__planer2AgentUhr'
|
||||
|
||||
async function pruefeAgent() {
|
||||
if (!projektName) return
|
||||
let stand
|
||||
try {
|
||||
stand = await fetchAuftrag(projektName)
|
||||
} catch {
|
||||
return // kurzer Aussetzer — die nächste Runde fragt wieder
|
||||
}
|
||||
agentLaeuft.value = stand.laeuft
|
||||
agentSchritte.value = stand.schritte || []
|
||||
agentAktivitaet.value = stand.aktivitaet || ''
|
||||
if (stand.laeuft) return
|
||||
stoppeUhr(UHR)
|
||||
if (stand.fehler) agentMeldung.value = `Agent: ${stand.fehler}`
|
||||
else if (stand.uebernommen) {
|
||||
agentMeldung.value = 'Claude hat die Planung implementiert — sie ist jetzt der Projektstand.'
|
||||
agentBericht.value = stand.text
|
||||
agentUebernommen.value = true
|
||||
await ladeStand(projektName) // die Planung ist weg, der Schalter fällt zurück
|
||||
}
|
||||
}
|
||||
|
||||
function beobachteAgent() {
|
||||
agentLaeuft.value = true
|
||||
agentMeldung.value = ''
|
||||
agentBericht.value = ''
|
||||
agentSchritte.value = []
|
||||
agentAktivitaet.value = ''
|
||||
agentUebernommen.value = false
|
||||
starteUhr(UHR, pruefeAgent, 2000)
|
||||
pruefeAgent()
|
||||
}
|
||||
|
||||
export async function brichAb() {
|
||||
if (!projektName) return false
|
||||
meldung.value = ''
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { starteUhr, stoppeUhr } from './uhr.js'
|
||||
import { pruefungStarten, fetchPruefung, pruefungBeenden } from '../api.js'
|
||||
|
||||
// Ein Prüflauf für das aktive Projekt; Zustand global, damit Sidebar,
|
||||
@@ -13,7 +14,7 @@ export const probleme = ref([])
|
||||
|
||||
const gesehen = new Set() // schon übernommene Probleme holt der nächste Abruf nicht erneut
|
||||
let projektName = null
|
||||
let uhr = null
|
||||
const UHR = '__planer2PruefUhr'
|
||||
|
||||
export const prozent = computed(() =>
|
||||
gesamt.value ? Math.round((fertig.value / gesamt.value) * 100) : 0)
|
||||
@@ -98,10 +99,20 @@ export function ergaenze(neue) {
|
||||
const k = schluessel(p)
|
||||
if (gesehen.has(k)) continue
|
||||
gesehen.add(k)
|
||||
// Markiert, woher der Vorschlag kommt: Gesprächsvorschläge überleben es,
|
||||
// wenn eine Prüfung startet oder ihr Panel zugeht — sie gehören dem Gespräch.
|
||||
p.gespraech = true
|
||||
probleme.value.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Nur die Prüfergebnisse wegräumen; was das Gespräch vorgeschlagen hat, bleibt
|
||||
function nurGespraech() {
|
||||
probleme.value = probleme.value.filter(p => p.gespraech)
|
||||
gesehen.clear()
|
||||
probleme.value.forEach(p => gesehen.add(schluessel(p)))
|
||||
}
|
||||
|
||||
// Ein angenommener Titelvorschlag benennt Karte oder Funktion um. Die übrigen Probleme
|
||||
// zeigen dann noch auf den alten Namen und wären in der Karte nicht mehr auffindbar.
|
||||
export function benenneUm(featureId, feld, alt, neu) {
|
||||
@@ -117,12 +128,7 @@ async function abfragen() {
|
||||
meldung.value = e.message
|
||||
laeuft.value = false
|
||||
}
|
||||
if (!laeuft.value) stoppeUhr()
|
||||
}
|
||||
|
||||
function stoppeUhr() {
|
||||
clearInterval(uhr)
|
||||
uhr = null
|
||||
if (!laeuft.value) stoppeUhr(UHR)
|
||||
}
|
||||
|
||||
export async function starte(projekt, pruefart = 'stil') {
|
||||
@@ -130,8 +136,7 @@ export async function starte(projekt, pruefart = 'stil') {
|
||||
art.value = pruefart
|
||||
offen.value = true
|
||||
meldung.value = ''
|
||||
gesehen.clear()
|
||||
probleme.value = []
|
||||
nurGespraech()
|
||||
try {
|
||||
uebernehmen(await pruefungStarten(projekt, pruefart))
|
||||
} catch (e) {
|
||||
@@ -148,8 +153,8 @@ export async function starte(projekt, pruefart = 'stil') {
|
||||
return
|
||||
}
|
||||
}
|
||||
stoppeUhr()
|
||||
uhr = setInterval(abfragen, 1000)
|
||||
stoppeUhr(UHR)
|
||||
starteUhr(UHR, abfragen, 1000)
|
||||
}
|
||||
|
||||
// Angenommenes oder abgelehntes Problem verschwindet, die übrigen bleiben stehen.
|
||||
@@ -165,9 +170,20 @@ export function verwirf(problem) {
|
||||
}
|
||||
|
||||
export async function schliesse() {
|
||||
stoppeUhr()
|
||||
stoppeUhr(UHR)
|
||||
offen.value = false
|
||||
probleme.value = []
|
||||
laeuft.value = false
|
||||
nurGespraech()
|
||||
if (projektName) await pruefungBeenden(projektName).catch(() => {})
|
||||
}
|
||||
|
||||
// Projektwechsel: hier gehört alles weg, auch die Gesprächsvorschläge — sie
|
||||
// zeigten sonst auf gleichnamige Karten des nächsten Projekts.
|
||||
export async function verwerfeAlles() {
|
||||
stoppeUhr(UHR)
|
||||
offen.value = false
|
||||
laeuft.value = false
|
||||
probleme.value = []
|
||||
gesehen.clear()
|
||||
if (projektName) await pruefungBeenden(projektName).catch(() => {})
|
||||
}
|
||||
|
||||
19
frontend/src/composables/uhr.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Wiederkehrende Abfragen — ein Intervall je Name.
|
||||
//
|
||||
// Der Griff liegt am globalen Objekt, nicht im Modul. Vite tauscht in der Entwicklung
|
||||
// bei jeder Änderung das Modul aus und führt es neu aus; das laufende Intervall räumt
|
||||
// es dabei nicht ab. Die Modulvariable wäre danach leer, der alte Timer unerreichbar —
|
||||
// und er fragt weiter. Nach einer Handvoll Änderungen pollten fünf Uhren parallel.
|
||||
//
|
||||
// `import.meta.hot.dispose` reicht dafür nicht: Vite ruft es nur bei Modulen, die den
|
||||
// Austausch selbst annehmen. Ein Name am globalen Objekt überlebt jeden Tausch.
|
||||
|
||||
export function starteUhr(name, aufgabe, ms) {
|
||||
stoppeUhr(name)
|
||||
globalThis[name] = setInterval(aufgabe, ms)
|
||||
}
|
||||
|
||||
export function stoppeUhr(name) {
|
||||
clearInterval(globalThis[name])
|
||||
globalThis[name] = null
|
||||
}
|
||||