mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 17:53:19 +08:00
feat(pm): add bug-report button with screenshot annotation and admin panel
- New BugReport MongoDB model (comment, contact, userId, screenshot, pmState, status) - POST /api/bug-reports — authenticated submit; emails emanuel@faktorxmensch.com on receipt - GET/PATCH /api/admin/bug-reports + /[id] — admin list, detail with screenshot, status toggle - /pm: "Bug" button in HUD captures viewport screenshot (html2canvas), shows annotation canvas where testers can draw arrows; submits comment + contact + state snapshot - /admin: new "Bug Reports" tab with open-count badge, screenshot expand, "Erledigt" toggle, and "In /pm öffnen" link that restores captured engine state via ?restoreBugReport=<id> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -43,6 +43,10 @@
|
||||
<v-tab value="invitations">Invitations</v-tab>
|
||||
<v-tab value="waitlist">Waitlist</v-tab>
|
||||
<v-tab value="logs">Transmissions</v-tab>
|
||||
<v-tab value="bug-reports">
|
||||
Bug Reports
|
||||
<v-badge v-if="bugReportOpenCount > 0" :content="bugReportOpenCount" color="red" inline class="ml-1" />
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-window v-model="activeTab" class="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur">
|
||||
@@ -932,6 +936,145 @@
|
||||
</section>
|
||||
</v-window-item>
|
||||
|
||||
<!-- Bug Reports Tab -->
|
||||
<v-window-item value="bug-reports">
|
||||
<section class="space-y-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">Bug Reports</h2>
|
||||
<p class="text-sm text-white/70">Fehlermeldungen von Testern aus der /pm-Seite.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<v-select
|
||||
v-model="bugReportStatusFilter"
|
||||
:items="[{ title: 'Offen', value: 'open' }, { title: 'Erledigt', value: 'resolved' }]"
|
||||
label="Status"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="cyan"
|
||||
hide-details
|
||||
class="w-40"
|
||||
/>
|
||||
<v-btn color="cyan" variant="tonal" :loading="bugReportLoading" @click="fetchBugReports(true)">
|
||||
Laden
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="bugReportError" type="warning" variant="tonal" density="comfortable" class="bg-red-500/10 text-red-100">
|
||||
{{ bugReportError }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="bugReportLoading && !bugReports.length" class="py-12 text-center text-white/70">
|
||||
<v-progress-circular indeterminate color="cyan" class="mb-4" />
|
||||
<p>Bug Reports laden…</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div v-if="bugReports.length" class="space-y-4">
|
||||
<v-card
|
||||
v-for="report in bugReports"
|
||||
:key="report.id"
|
||||
class="border border-white/10 bg-black/40"
|
||||
>
|
||||
<v-card-text class="space-y-3">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<v-chip size="x-small" :color="report.status === 'open' ? 'red' : 'green'" variant="tonal">
|
||||
{{ report.status === 'open' ? 'Offen' : 'Erledigt' }}
|
||||
</v-chip>
|
||||
<span class="text-xs text-white/40">{{ formatRelative(report.createdAt) }}</span>
|
||||
<span v-if="report.hasScreenshot" class="text-xs text-cyan-300/60">
|
||||
<v-icon size="12">mdi-image</v-icon> Screenshot
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-semibold text-white text-sm">{{ report.contact }}</p>
|
||||
<p v-if="report.user" class="text-xs text-white/50">{{ report.user.email }}</p>
|
||||
<p class="text-sm text-white/80 whitespace-pre-line">{{ report.comment }}</p>
|
||||
<div v-if="report.pmState?.currentStateId" class="text-xs text-white/40 font-mono">
|
||||
State: {{ report.pmState.currentStateId }} · Flow: {{ report.pmState.flowSlug || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 items-start md:items-end shrink-0">
|
||||
<v-btn
|
||||
v-if="report.status === 'open'"
|
||||
size="small"
|
||||
color="green"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-check"
|
||||
:loading="bugReportResolving === report.id"
|
||||
@click="resolveBugReport(report.id)"
|
||||
>
|
||||
Erledigt
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
size="small"
|
||||
color="orange"
|
||||
variant="text"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="bugReportResolving === report.id"
|
||||
@click="reopenBugReport(report.id)"
|
||||
>
|
||||
Wieder öffnen
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="report.hasScreenshot"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="cyan"
|
||||
prepend-icon="mdi-image"
|
||||
@click="loadBugReportDetail(report.id)"
|
||||
>
|
||||
Screenshot
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="report.pmState?.flowSlug"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="cyan"
|
||||
prepend-icon="mdi-play-circle-outline"
|
||||
:href="`/pm?restoreBugReport=${report.id}`"
|
||||
target="_blank"
|
||||
>
|
||||
In /pm öffnen
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot detail (loaded on demand) -->
|
||||
<div v-if="bugReportDetailId === report.id">
|
||||
<div v-if="bugReportDetailLoading" class="py-4 text-center text-white/60">
|
||||
<v-progress-circular indeterminate color="cyan" size="24" />
|
||||
</div>
|
||||
<img
|
||||
v-else-if="bugReportDetail?.screenshot"
|
||||
:src="bugReportDetail.screenshot"
|
||||
class="w-full rounded-xl border border-white/10"
|
||||
alt="Screenshot"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<p v-else class="py-12 text-center text-sm text-white/60">Keine Bug Reports vorhanden.</p>
|
||||
|
||||
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
|
||||
<div class="text-xs text-white/50">
|
||||
Seite {{ bugReportPagination.page }} von {{ bugReportPagination.pages }} · {{ bugReportPagination.total }} Reports
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<v-btn variant="text" color="cyan" :disabled="bugReportPagination.page <= 1" @click="changeBugReportPage(bugReportPagination.page - 1)">Zurück</v-btn>
|
||||
<v-btn variant="text" color="cyan" :disabled="bugReportPagination.page >= bugReportPagination.pages" @click="changeBugReportPage(bugReportPagination.page + 1)">Weiter</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</v-window-item>
|
||||
|
||||
</v-window>
|
||||
</div>
|
||||
|
||||
@@ -1280,13 +1423,33 @@ interface CreateInviteResponse {
|
||||
}
|
||||
}
|
||||
|
||||
interface BugReportItem {
|
||||
id: string
|
||||
comment: string
|
||||
contact: string
|
||||
user?: { id: string; email: string; name?: string }
|
||||
pmState?: { flowSlug?: string; scenarioId?: string; currentStateId?: string }
|
||||
status: 'open' | 'resolved'
|
||||
createdAt: string | null
|
||||
hasScreenshot: boolean
|
||||
}
|
||||
|
||||
interface BugReportDetail extends BugReportItem {
|
||||
screenshot: string | null
|
||||
}
|
||||
|
||||
interface BugReportsResponse {
|
||||
items: BugReportItem[]
|
||||
pagination: { total: number; page: number; pageSize: number; pages: number }
|
||||
}
|
||||
|
||||
definePageMeta({ middleware: 'require-admin' })
|
||||
useHead({ title: 'Admin • OpenSquawk' })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
|
||||
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs'>('overview')
|
||||
const activeTab = ref<'overview' | 'users' | 'invitations' | 'waitlist' | 'logs' | 'bug-reports'>('overview')
|
||||
const refreshing = ref(false)
|
||||
|
||||
const overview = ref<OverviewData | null>(null)
|
||||
@@ -1890,11 +2053,104 @@ watch(activeTab, (tab) => {
|
||||
fetchWaitlist(true)
|
||||
} else if (tab === 'logs' && !sessionsLoaded.value) {
|
||||
fetchSessions(true)
|
||||
} else if (tab === 'bug-reports' && !bugReportsLoaded.value) {
|
||||
fetchBugReports(true)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Bug Reports ──────────────────────────────────────────────────────────────
|
||||
const bugReports = ref<BugReportItem[]>([])
|
||||
const bugReportPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 20 })
|
||||
const bugReportLoading = ref(false)
|
||||
const bugReportError = ref('')
|
||||
const bugReportsLoaded = ref(false)
|
||||
const bugReportStatusFilter = ref<'open' | 'resolved'>('open')
|
||||
const bugReportOpenCount = ref(0)
|
||||
const bugReportResolving = ref<string | null>(null)
|
||||
const bugReportDetailId = ref<string | null>(null)
|
||||
const bugReportDetail = ref<BugReportDetail | null>(null)
|
||||
const bugReportDetailLoading = ref(false)
|
||||
|
||||
async function fetchBugReports(resetPage = false) {
|
||||
if (resetPage) bugReportPagination.page = 1
|
||||
bugReportLoading.value = true
|
||||
bugReportError.value = ''
|
||||
try {
|
||||
const response = await api.get<BugReportsResponse>('/api/admin/bug-reports', {
|
||||
query: { status: bugReportStatusFilter.value, page: bugReportPagination.page },
|
||||
})
|
||||
bugReports.value = response.items
|
||||
Object.assign(bugReportPagination, response.pagination)
|
||||
bugReportsLoaded.value = true
|
||||
if (bugReportStatusFilter.value === 'open') bugReportOpenCount.value = response.pagination.total
|
||||
} catch (error) {
|
||||
bugReportError.value = extractErrorMessage(error, 'Bug Reports konnten nicht geladen werden.')
|
||||
} finally {
|
||||
bugReportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBugReportDetail(id: string) {
|
||||
if (bugReportDetailId.value === id) {
|
||||
bugReportDetailId.value = null
|
||||
bugReportDetail.value = null
|
||||
return
|
||||
}
|
||||
bugReportDetailId.value = id
|
||||
bugReportDetail.value = null
|
||||
bugReportDetailLoading.value = true
|
||||
try {
|
||||
bugReportDetail.value = await api.get<BugReportDetail>(`/api/admin/bug-reports/${id}`)
|
||||
} catch {
|
||||
bugReportDetailId.value = null
|
||||
} finally {
|
||||
bugReportDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBugReport(id: string) {
|
||||
bugReportResolving.value = id
|
||||
try {
|
||||
await api.request(`/api/admin/bug-reports/${id}`, { method: 'PATCH', body: { status: 'resolved' } })
|
||||
const report = bugReports.value.find((r) => r.id === id)
|
||||
if (report) { report.status = 'resolved'; bugReportOpenCount.value = Math.max(0, bugReportOpenCount.value - 1) }
|
||||
if (bugReportStatusFilter.value === 'open') bugReports.value = bugReports.value.filter((r) => r.id !== id)
|
||||
} catch (error) {
|
||||
bugReportError.value = extractErrorMessage(error, 'Status konnte nicht geändert werden.')
|
||||
} finally {
|
||||
bugReportResolving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function reopenBugReport(id: string) {
|
||||
bugReportResolving.value = id
|
||||
try {
|
||||
await api.request(`/api/admin/bug-reports/${id}`, { method: 'PATCH', body: { status: 'open' } })
|
||||
const report = bugReports.value.find((r) => r.id === id)
|
||||
if (report) { report.status = 'open'; bugReportOpenCount.value++ }
|
||||
if (bugReportStatusFilter.value === 'resolved') bugReports.value = bugReports.value.filter((r) => r.id !== id)
|
||||
} catch (error) {
|
||||
bugReportError.value = extractErrorMessage(error, 'Status konnte nicht geändert werden.')
|
||||
} finally {
|
||||
bugReportResolving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function changeBugReportPage(page: number) {
|
||||
if (page < 1 || page > bugReportPagination.pages) return
|
||||
bugReportPagination.page = page
|
||||
fetchBugReports()
|
||||
}
|
||||
|
||||
watch(bugReportStatusFilter, () => fetchBugReports(true))
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
loadOverview(true)
|
||||
// Load open bug report count for the badge
|
||||
api.get<BugReportsResponse>('/api/admin/bug-reports', { query: { status: 'open', page: 1 } })
|
||||
.then((r) => { bugReportOpenCount.value = r.pagination.total })
|
||||
.catch(() => {})
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
296
app/pages/pm.vue
296
app/pages/pm.vue
@@ -467,6 +467,15 @@
|
||||
</div>
|
||||
|
||||
<div class="hud-right">
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
title="Fehler melden"
|
||||
@click="openBugReport"
|
||||
>
|
||||
<v-icon size="18">mdi-bug-outline</v-icon>
|
||||
<span class="btn-label">Bug</span>
|
||||
</button>
|
||||
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
|
||||
<v-icon size="18">mdi-message-draw</v-icon>
|
||||
<span class="btn-label">Feedback</span>
|
||||
@@ -1198,6 +1207,107 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Bug Report Dialog -->
|
||||
<v-dialog v-model="showBugReportDialog" max-width="680" scrollable>
|
||||
<v-card class="bg-[#0b101d] border border-white/10 text-white">
|
||||
<v-card-title class="flex items-center gap-2 text-base font-semibold pt-4 px-5">
|
||||
<v-icon icon="mdi-bug-outline" color="#f87171" size="20" />
|
||||
Fehler melden
|
||||
</v-card-title>
|
||||
<v-card-text class="space-y-4 px-5 pb-2">
|
||||
<div v-if="bugReportSuccess" class="rounded-xl bg-emerald-500/10 border border-emerald-400/30 p-4 text-center">
|
||||
<v-icon icon="mdi-check-circle-outline" color="emerald" size="32" class="mb-2" />
|
||||
<p class="text-emerald-300 font-semibold">Danke! Bug Report wurde gesendet.</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="bugReportScreenshot" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-white/40">Screenshot – Pfeile einzeichnen</p>
|
||||
<v-btn
|
||||
v-if="bugReportArrows.length > 0"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="red"
|
||||
prepend-icon="mdi-undo"
|
||||
@click="undoLastArrow"
|
||||
>
|
||||
Rückgängig
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="relative rounded-xl overflow-hidden border border-white/10" style="line-height:0">
|
||||
<img
|
||||
ref="bugReportImgRef"
|
||||
:src="bugReportScreenshot"
|
||||
class="w-full block"
|
||||
alt="Screenshot"
|
||||
@load="setupAnnotationCanvas"
|
||||
/>
|
||||
<canvas
|
||||
ref="bugReportCanvasRef"
|
||||
class="absolute inset-0 w-full h-full cursor-crosshair select-none"
|
||||
style="touch-action:none"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
@mousemove="onCanvasMouseMove"
|
||||
@mouseup="onCanvasMouseUp"
|
||||
@mouseleave="onCanvasMouseLeave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rounded-xl border border-white/10 bg-white/5 p-4 text-center text-sm text-white/50">
|
||||
Kein Screenshot verfügbar
|
||||
</div>
|
||||
|
||||
<v-textarea
|
||||
v-model="bugReportComment"
|
||||
label="Was ist kaputt? Was sollte stattdessen passieren?"
|
||||
variant="outlined"
|
||||
color="red"
|
||||
rows="3"
|
||||
auto-grow
|
||||
maxlength="2000"
|
||||
counter="2000"
|
||||
hide-details="auto"
|
||||
:disabled="bugReportLoading"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="bugReportContact"
|
||||
label="Dein Name / Kontakt"
|
||||
variant="outlined"
|
||||
color="red"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
:disabled="bugReportLoading"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="bugReportError"
|
||||
type="error"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
class="bg-red-500/10 text-red-200"
|
||||
>
|
||||
{{ bugReportError }}
|
||||
</v-alert>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions v-if="!bugReportSuccess" class="justify-end gap-2 px-5 pb-4">
|
||||
<v-btn variant="text" color="grey" :disabled="bugReportLoading" @click="showBugReportDialog = false">
|
||||
Abbrechen
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="red"
|
||||
variant="flat"
|
||||
:loading="bugReportLoading"
|
||||
:disabled="!bugReportComment.trim()"
|
||||
@click="submitBugReport"
|
||||
>
|
||||
Bug melden
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Transmission issue dialog -->
|
||||
<v-dialog v-model="showTransmissionIssueDialog" max-width="420">
|
||||
<v-card class="bg-[#0b101d] border border-white/10 text-white">
|
||||
@@ -1242,7 +1352,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
|
||||
import { normalizeRadioPhrase, normalizeAtisForSpeech, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech';
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
@@ -1292,6 +1402,7 @@ const engine = useCommunicationsEngine()
|
||||
const auth = useAuthStore()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const radioBackend = useRadioBackend()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
@@ -1992,6 +2103,168 @@ const radioEffectsEnabled = ref(true)
|
||||
const readbackEnabled = ref(false)
|
||||
const debugMode = ref(true)
|
||||
|
||||
// ── Bug Report ───────────────────────────────────────────────────────────────
|
||||
const showBugReportDialog = ref(false)
|
||||
const bugReportComment = ref('')
|
||||
const bugReportContact = ref('')
|
||||
const bugReportScreenshot = ref<string | null>(null)
|
||||
const bugReportArrows = ref<Array<{ fx: number; fy: number; tx: number; ty: number }>>([])
|
||||
const bugReportLoading = ref(false)
|
||||
const bugReportError = ref('')
|
||||
const bugReportSuccess = ref(false)
|
||||
const bugReportCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const bugReportImgRef = ref<HTMLImageElement | null>(null)
|
||||
let _arrowDrawing = false
|
||||
let _arrowStart = { x: 0, y: 0 }
|
||||
|
||||
function setupAnnotationCanvas() {
|
||||
const canvas = bugReportCanvasRef.value
|
||||
const img = bugReportImgRef.value
|
||||
if (!canvas || !img) return
|
||||
canvas.width = img.clientWidth
|
||||
canvas.height = img.clientHeight
|
||||
}
|
||||
|
||||
function _drawArrow(ctx: CanvasRenderingContext2D, fx: number, fy: number, tx: number, ty: number) {
|
||||
const headLen = 14
|
||||
const angle = Math.atan2(ty - fy, tx - fx)
|
||||
ctx.strokeStyle = '#ef4444'
|
||||
ctx.fillStyle = '#ef4444'
|
||||
ctx.lineWidth = 3
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(fx, fy)
|
||||
ctx.lineTo(tx, ty)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(tx, ty)
|
||||
ctx.lineTo(tx - headLen * Math.cos(angle - Math.PI / 6), ty - headLen * Math.sin(angle - Math.PI / 6))
|
||||
ctx.lineTo(tx - headLen * Math.cos(angle + Math.PI / 6), ty - headLen * Math.sin(angle + Math.PI / 6))
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
function _redrawAnnotations(preview?: { fx: number; fy: number; tx: number; ty: number }) {
|
||||
const canvas = bugReportCanvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx, a.fy, a.tx, a.ty)
|
||||
if (preview) {
|
||||
ctx.globalAlpha = 0.55
|
||||
_drawArrow(ctx, preview.fx, preview.fy, preview.tx, preview.ty)
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
function _canvasCoords(e: MouseEvent) {
|
||||
const canvas = bugReportCanvasRef.value!
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
|
||||
function onCanvasMouseDown(e: MouseEvent) {
|
||||
_arrowStart = _canvasCoords(e)
|
||||
_arrowDrawing = true
|
||||
}
|
||||
|
||||
function onCanvasMouseMove(e: MouseEvent) {
|
||||
if (!_arrowDrawing) return
|
||||
const { x, y } = _canvasCoords(e)
|
||||
_redrawAnnotations({ fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y })
|
||||
}
|
||||
|
||||
function onCanvasMouseUp(e: MouseEvent) {
|
||||
if (!_arrowDrawing) return
|
||||
_arrowDrawing = false
|
||||
const { x, y } = _canvasCoords(e)
|
||||
const dx = x - _arrowStart.x
|
||||
const dy = y - _arrowStart.y
|
||||
if (Math.sqrt(dx * dx + dy * dy) < 8) { _redrawAnnotations(); return }
|
||||
bugReportArrows.value = [...bugReportArrows.value, { fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y }]
|
||||
_redrawAnnotations()
|
||||
}
|
||||
|
||||
function onCanvasMouseLeave() {
|
||||
if (!_arrowDrawing) return
|
||||
_arrowDrawing = false
|
||||
_redrawAnnotations()
|
||||
}
|
||||
|
||||
function undoLastArrow() {
|
||||
bugReportArrows.value = bugReportArrows.value.slice(0, -1)
|
||||
_redrawAnnotations()
|
||||
}
|
||||
|
||||
async function openBugReport() {
|
||||
bugReportError.value = ''
|
||||
bugReportSuccess.value = false
|
||||
bugReportComment.value = ''
|
||||
bugReportArrows.value = []
|
||||
bugReportScreenshot.value = null
|
||||
bugReportContact.value = [auth.user?.name, auth.user?.email].filter(Boolean).join(' — ')
|
||||
|
||||
try {
|
||||
const { default: html2canvas } = await import('html2canvas')
|
||||
const c = await html2canvas(document.body, { scale: 0.55, useCORS: true, allowTaint: true, logging: false })
|
||||
bugReportScreenshot.value = c.toDataURL('image/jpeg', 0.75)
|
||||
} catch { /* Screenshot optional */ }
|
||||
|
||||
showBugReportDialog.value = true
|
||||
}
|
||||
|
||||
async function submitBugReport() {
|
||||
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte einen Kommentar eingeben.'; return }
|
||||
bugReportLoading.value = true
|
||||
bugReportError.value = ''
|
||||
|
||||
try {
|
||||
let finalScreenshot: string | undefined
|
||||
if (bugReportScreenshot.value) {
|
||||
const img = bugReportImgRef.value
|
||||
const src = new Image()
|
||||
await new Promise<void>((res) => { src.onload = () => res(); src.src = bugReportScreenshot.value! })
|
||||
const out = document.createElement('canvas')
|
||||
out.width = src.naturalWidth; out.height = src.naturalHeight
|
||||
const ctx = out.getContext('2d')!
|
||||
ctx.drawImage(src, 0, 0)
|
||||
if (bugReportArrows.value.length > 0 && img) {
|
||||
const sx = src.naturalWidth / img.clientWidth
|
||||
const sy = src.naturalHeight / img.clientHeight
|
||||
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx * sx, a.fy * sy, a.tx * sx, a.ty * sy)
|
||||
}
|
||||
finalScreenshot = out.toDataURL('image/jpeg', 0.8)
|
||||
}
|
||||
|
||||
const pmState = {
|
||||
flowSlug: activeScenario.value?.startFlow || '',
|
||||
scenarioId: activeScenario.value?.id || '',
|
||||
currentStateId: (currentState.value as any)?.id || '',
|
||||
variables: (vars as any)?.value || {},
|
||||
flags: (flags as any)?.value || {},
|
||||
flightContext: (flightContext as any)?.value || {},
|
||||
communicationLog: ((log as any)?.value || [] as any[]).slice(-20),
|
||||
}
|
||||
|
||||
await api.post('/api/bug-reports', {
|
||||
comment: bugReportComment.value.trim(),
|
||||
contact: bugReportContact.value.trim(),
|
||||
screenshot: finalScreenshot,
|
||||
pmState,
|
||||
})
|
||||
|
||||
bugReportSuccess.value = true
|
||||
setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 2500)
|
||||
} catch (err: any) {
|
||||
bugReportError.value = err?.data?.statusMessage || err?.message || 'Fehler beim Senden.'
|
||||
} finally {
|
||||
bugReportLoading.value = false
|
||||
}
|
||||
}
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Pre-recording (rolling mic buffer) so the first ~1s of PTT speech isn't clipped
|
||||
const prerecEnabled = ref(true)
|
||||
const prerecSeconds = ref(1.0)
|
||||
@@ -2151,6 +2424,27 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore state from bug report (admin link: /pm?restoreBugReport=<id>)
|
||||
const restoreId = route.query.restoreBugReport as string | undefined
|
||||
if (restoreId) {
|
||||
try {
|
||||
const report = await api.get<any>(`/api/admin/bug-reports/${restoreId}`)
|
||||
const state = report?.pmState
|
||||
if (state) {
|
||||
if (state.variables && Object.keys(state.variables).length) patchVariables(state.variables)
|
||||
if (state.flags && Object.keys(state.flags).length) patchFlags(state.flags)
|
||||
if (state.flowSlug) {
|
||||
try {
|
||||
await fetchRuntimeTree(state.flowSlug, config.public.radioBackendUrl as string)
|
||||
} catch {}
|
||||
}
|
||||
error.value = `[Bug-Report-Restore] State: ${state.currentStateId || '?'} · Flow: ${state.flowSlug || '?'}`
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[PM] Bug report restore failed', err)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
restoringFromStorage = false
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-aos": "^1.2.6",
|
||||
|
||||
27
server/api/admin/bug-reports/[id].get.ts
Normal file
27
server/api/admin/bug-reports/[id].get.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
||||
import { requireAdmin } from '../../../utils/auth'
|
||||
import { BugReport } from '../../../models/BugReport'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
const doc = await BugReport.findById(id).populate('userId', 'email name role').lean() as any
|
||||
|
||||
if (!doc) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Bug report not found' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(doc._id),
|
||||
comment: doc.comment,
|
||||
contact: doc.contact,
|
||||
user: doc.userId
|
||||
? { id: String((doc.userId as any)._id), email: (doc.userId as any).email, name: (doc.userId as any).name }
|
||||
: undefined,
|
||||
screenshot: doc.screenshot || null,
|
||||
pmState: doc.pmState || null,
|
||||
status: doc.status,
|
||||
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null,
|
||||
}
|
||||
})
|
||||
27
server/api/admin/bug-reports/[id].patch.ts
Normal file
27
server/api/admin/bug-reports/[id].patch.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
||||
import { requireAdmin } from '../../../utils/auth'
|
||||
import { BugReport } from '../../../models/BugReport'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
const status = body?.status
|
||||
if (status !== 'open' && status !== 'resolved') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'status must be "open" or "resolved"' })
|
||||
}
|
||||
|
||||
const doc = await BugReport.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: { status } },
|
||||
{ new: true },
|
||||
).lean() as any
|
||||
|
||||
if (!doc) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Bug report not found' })
|
||||
}
|
||||
|
||||
return { id: String(doc._id), status: doc.status }
|
||||
})
|
||||
78
server/api/admin/bug-reports/index.get.ts
Normal file
78
server/api/admin/bug-reports/index.get.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { defineEventHandler, getQuery } from 'h3'
|
||||
import { requireAdmin } from '../../../utils/auth'
|
||||
import { BugReport } from '../../../models/BugReport'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const page = Math.max(parseInt(String(query.page ?? '1'), 10) || 1, 1)
|
||||
const pageSize = 20
|
||||
const skip = (page - 1) * pageSize
|
||||
const status = String(query.status ?? 'open')
|
||||
|
||||
const filter: Record<string, any> = {}
|
||||
if (status === 'open' || status === 'resolved') {
|
||||
filter.status = status
|
||||
}
|
||||
|
||||
const [docs, total] = await Promise.all([
|
||||
BugReport.aggregate([
|
||||
{ $match: filter },
|
||||
{ $sort: { createdAt: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: pageSize },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'userId',
|
||||
foreignField: '_id',
|
||||
as: 'userDoc',
|
||||
pipeline: [{ $project: { email: 1, name: 1, role: 1 } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
comment: 1,
|
||||
contact: 1,
|
||||
userId: 1,
|
||||
userDoc: 1,
|
||||
status: 1,
|
||||
createdAt: 1,
|
||||
'pmState.flowSlug': 1,
|
||||
'pmState.scenarioId': 1,
|
||||
'pmState.currentStateId': 1,
|
||||
hasScreenshot: { $gt: [{ $strLenCP: { $ifNull: ['$screenshot', ''] } }, 0] },
|
||||
},
|
||||
},
|
||||
]).exec(),
|
||||
BugReport.countDocuments(filter),
|
||||
])
|
||||
|
||||
const items = docs.map((doc: any) => ({
|
||||
id: String(doc._id),
|
||||
comment: doc.comment,
|
||||
contact: doc.contact,
|
||||
user: doc.userDoc?.[0]
|
||||
? { id: String(doc.userDoc[0]._id), email: doc.userDoc[0].email, name: doc.userDoc[0].name }
|
||||
: undefined,
|
||||
pmState: {
|
||||
flowSlug: doc.pmState?.flowSlug,
|
||||
scenarioId: doc.pmState?.scenarioId,
|
||||
currentStateId: doc.pmState?.currentStateId,
|
||||
},
|
||||
status: doc.status,
|
||||
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null,
|
||||
hasScreenshot: Boolean(doc.hasScreenshot),
|
||||
}))
|
||||
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
pages: Math.ceil(total / pageSize) || 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
44
server/api/bug-reports/index.post.ts
Normal file
44
server/api/bug-reports/index.post.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
import { BugReport } from '../../models/BugReport'
|
||||
import { sendMail } from '../../utils/notifications'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
const body = await readBody(event)
|
||||
|
||||
const comment = String(body?.comment ?? '').trim()
|
||||
if (!comment) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Kommentar ist erforderlich' })
|
||||
}
|
||||
|
||||
const contact = String(
|
||||
body?.contact || [user.name, user.email].filter(Boolean).join(' — ')
|
||||
).slice(0, 200)
|
||||
|
||||
const report = await BugReport.create({
|
||||
comment: comment.slice(0, 4000),
|
||||
contact,
|
||||
userId: user._id,
|
||||
screenshot: body?.screenshot || undefined,
|
||||
pmState: body?.pmState || undefined,
|
||||
})
|
||||
|
||||
const adminUrl = `${process.env.APP_URL || 'https://app.opensquawk.de'}/admin`
|
||||
const stateInfo = body?.pmState?.currentStateId
|
||||
? `State: ${body.pmState.currentStateId} (Flow: ${body.pmState.flowSlug || '—'})`
|
||||
: ''
|
||||
|
||||
await sendMail({
|
||||
to: 'emanuel@faktorxmensch.com',
|
||||
subject: `[OpenSquawk Bug] ${contact}`,
|
||||
html: `<h2>Neuer Bug Report</h2>
|
||||
<p><strong>Von:</strong> ${contact}</p>
|
||||
<p><strong>Kommentar:</strong><br>${comment.replace(/\n/g, '<br>')}</p>
|
||||
${stateInfo ? `<p><strong>${stateInfo}</strong></p>` : ''}
|
||||
<p><a href="${adminUrl}">Im Admin-Panel ansehen →</a></p>`,
|
||||
text: `Bug Report von ${contact}\n\n${comment}\n${stateInfo}\n\nAdmin: ${adminUrl}`,
|
||||
}).catch(() => {})
|
||||
|
||||
return { success: true, id: String(report._id) }
|
||||
})
|
||||
45
server/models/BugReport.ts
Normal file
45
server/models/BugReport.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
export type BugReportStatus = 'open' | 'resolved'
|
||||
|
||||
export interface PmStateSnapshot {
|
||||
flowSlug?: string
|
||||
scenarioId?: string
|
||||
currentStateId?: string
|
||||
variables?: Record<string, any>
|
||||
flags?: Record<string, boolean>
|
||||
flightContext?: Record<string, any>
|
||||
communicationLog?: any[]
|
||||
}
|
||||
|
||||
export interface BugReportDocument extends mongoose.Document {
|
||||
comment: string
|
||||
contact: string
|
||||
userId?: mongoose.Types.ObjectId
|
||||
screenshot?: string
|
||||
pmState?: PmStateSnapshot
|
||||
status: BugReportStatus
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
const bugReportSchema = new mongoose.Schema<BugReportDocument>({
|
||||
comment: { type: String, required: true, trim: true, maxlength: 4000 },
|
||||
contact: { type: String, required: true, trim: true, maxlength: 200 },
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },
|
||||
screenshot: { type: String },
|
||||
pmState: {
|
||||
flowSlug: { type: String },
|
||||
scenarioId: { type: String },
|
||||
currentStateId: { type: String },
|
||||
variables: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
flags: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
flightContext: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
communicationLog: { type: [mongoose.Schema.Types.Mixed], default: [] },
|
||||
},
|
||||
status: { type: String, enum: ['open', 'resolved'], default: 'open', index: true },
|
||||
createdAt: { type: Date, default: () => new Date(), index: true },
|
||||
})
|
||||
|
||||
export const BugReport =
|
||||
(mongoose.models.BugReport as mongoose.Model<BugReportDocument> | undefined) ||
|
||||
mongoose.model<BugReportDocument>('BugReport', bugReportSchema)
|
||||
45
yarn.lock
45
yarn.lock
@@ -4211,6 +4211,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"base64-arraybuffer@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "base64-arraybuffer@npm:1.0.2"
|
||||
checksum: 10c0/3acac95c70f9406e87a41073558ba85b6be9dbffb013a3d2a710e3f2d534d506c911847d5d9be4de458af6362c676de0a5c4c2d7bdf4def502d00b313368e72f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"base64-js@npm:^1.3.1":
|
||||
version: 1.5.1
|
||||
resolution: "base64-js@npm:1.5.1"
|
||||
@@ -4827,6 +4834,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-line-break@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "css-line-break@npm:2.1.0"
|
||||
dependencies:
|
||||
utrie: "npm:^1.0.2"
|
||||
checksum: 10c0/b2222d99d5daf7861ecddc050244fdce296fad74b000dcff6bdfb1eb16dc2ef0b9ffe2c1c965e3239bd05ebe9eadb6d5438a91592fa8648d27a338e827cf9048
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-select@npm:^5.1.0":
|
||||
version: 5.2.2
|
||||
resolution: "css-select@npm:5.2.2"
|
||||
@@ -6142,6 +6158,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html2canvas@npm:^1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "html2canvas@npm:1.4.1"
|
||||
dependencies:
|
||||
css-line-break: "npm:^2.1.0"
|
||||
text-segmentation: "npm:^1.0.3"
|
||||
checksum: 10c0/6de86f75762b00948edf2ea559f16da0a1ec3facc4a8a7d3f35fcec59bb0c5970463478988ae3d9082152e0173690d46ebf4082e7ac803dd4817bae1d355c0db
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"http-assert@npm:^1.3.0":
|
||||
version: 1.5.0
|
||||
resolution: "http-assert@npm:1.5.0"
|
||||
@@ -7976,6 +8002,7 @@ __metadata:
|
||||
"@types/three": "npm:^0.183.0"
|
||||
dotenv: "npm:^17.3.1"
|
||||
fluent-ffmpeg: "npm:^2.1.3"
|
||||
html2canvas: "npm:^1.4.1"
|
||||
nodemailer: "npm:^8.0.1"
|
||||
nuxt: "npm:^4.3.1"
|
||||
nuxt-aos: "npm:^1.2.6"
|
||||
@@ -10007,6 +10034,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"text-segmentation@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "text-segmentation@npm:1.0.3"
|
||||
dependencies:
|
||||
utrie: "npm:^1.0.2"
|
||||
checksum: 10c0/8b9ae8524e3a332371060d0ca62f10ad49a13e954719ea689a6c3a8b8c15c8a56365ede2bb91c322fb0d44b6533785f0da603e066b7554d052999967fb72d600
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"thenify-all@npm:^1.0.0":
|
||||
version: 1.6.0
|
||||
resolution: "thenify-all@npm:1.6.0"
|
||||
@@ -10598,6 +10634,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"utrie@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "utrie@npm:1.0.2"
|
||||
dependencies:
|
||||
base64-arraybuffer: "npm:^1.0.2"
|
||||
checksum: 10c0/eaffe645bd81a39e4bc3abb23df5895e9961dbdd49748ef3b173529e8b06ce9dd1163e9705d5309a1c61ee41ffcb825e2043bc0fd1659845ffbdf4b1515dfdb4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"v-lazy-show@npm:^0.2.4":
|
||||
version: 0.2.4
|
||||
resolution: "v-lazy-show@npm:0.2.4"
|
||||
|
||||
Reference in New Issue
Block a user