Initial commit: AMS Frontend - Vue 3 + PrimeVue
This commit is contained in:
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=https://api.ams.kronos-soulution.de/api
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ARG VITE_API_URL=https://api.ams.kronos-soulution.de/api
|
||||||
|
ENV VITE_APP_VERSION=$APP_VERSION
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
19
capacitor.config.ts
Normal file
19
capacitor.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli'
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'de.agentenbude.ams',
|
||||||
|
appName: 'AMS',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
url: 'https://ams.agentenbude.de',
|
||||||
|
cleartext: false,
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
buildOptions: {
|
||||||
|
keystorePath: undefined,
|
||||||
|
keystoreAlias: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Agent Management System</title>
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
nginx.conf
Normal file
15
nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
2710
package-lock.json
generated
Normal file
2710
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "ams-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^8.0.2",
|
||||||
|
"@capacitor/cli": "^8.0.2",
|
||||||
|
"@capacitor/core": "^8.0.2",
|
||||||
|
"@monaco-editor/loader": "^1.4.0",
|
||||||
|
"@primevue/themes": "^4.2.5",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"highlight.js": "^11.10.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primevue": "^4.2.5",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/App.vue
Normal file
27
src/App.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Toast from 'primevue/toast'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { initUpdateCheck } from './utils/appUpdate'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
authStore.loadFromStorage()
|
||||||
|
// Auto-Update Check (nur in Capacitor/Android App)
|
||||||
|
initUpdateCheck()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Toast position="top-right" />
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body, #app {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/api/index.ts
Normal file
64
src/api/index.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private baseUrl: string
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private getToken(): string | null {
|
||||||
|
return localStorage.getItem('ams_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = this.getToken()
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(path: string): Promise<T> {
|
||||||
|
return this.request('GET', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request('POST', path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
put<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request('PUT', path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
patch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request('PATCH', path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||||||
|
if (params) {
|
||||||
|
const query = new URLSearchParams(params).toString()
|
||||||
|
return this.request('DELETE', `${path}?${query}`)
|
||||||
|
}
|
||||||
|
return this.request('DELETE', path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiClient(API_BASE)
|
||||||
394
src/components/AgentFiles.vue
Normal file
394
src/components/AgentFiles.vue
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
|
interface AgentFile {
|
||||||
|
_id: string
|
||||||
|
agentId: string
|
||||||
|
filename: string
|
||||||
|
content?: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileGroup {
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
files: AgentFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
agentId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const files = ref<AgentFile[]>([])
|
||||||
|
const selectedFile = ref<AgentFile | null>(null)
|
||||||
|
const fileContent = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const fileLoading = ref(false)
|
||||||
|
|
||||||
|
const FILE_ICONS: Record<string, string> = {
|
||||||
|
'SOUL.md': '🧠',
|
||||||
|
'MEMORY.md': '💾',
|
||||||
|
'AGENTS.md': '📋',
|
||||||
|
'USER.md': '👤',
|
||||||
|
'TOOLS.md': '🔧',
|
||||||
|
'HEARTBEAT.md': '💓',
|
||||||
|
'IDENTITY.md': '🪪',
|
||||||
|
'WORKFLOW.md': '⚙️',
|
||||||
|
'BOOTSTRAP.md': '🚀',
|
||||||
|
'README.md': '📖',
|
||||||
|
'CHECKLIST.md': '✅'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(filename: string): string {
|
||||||
|
const basename = filename.includes('/') ? filename.split('/').pop()! : filename
|
||||||
|
return FILE_ICONS[basename] || '📄'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(filename: string): string {
|
||||||
|
return filename.includes('/') ? filename.split('/').pop()! : filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gruppierte Dateien: Root-Dateien + Unterordner
|
||||||
|
const groupedFiles = computed<FileGroup[]>(() => {
|
||||||
|
const rootFiles: AgentFile[] = []
|
||||||
|
const folders: Record<string, AgentFile[]> = {}
|
||||||
|
|
||||||
|
for (const file of files.value) {
|
||||||
|
if (file.filename.includes('/')) {
|
||||||
|
const folder = file.filename.split('/')[0]
|
||||||
|
if (!folders[folder]) folders[folder] = []
|
||||||
|
folders[folder].push(file)
|
||||||
|
} else {
|
||||||
|
rootFiles.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: FileGroup[] = []
|
||||||
|
|
||||||
|
if (rootFiles.length > 0) {
|
||||||
|
groups.push({ label: 'Workspace', icon: '📁', files: rootFiles })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [folder, folderFiles] of Object.entries(folders).sort()) {
|
||||||
|
const folderIcon = folder === 'memory' ? '📝' : '📂'
|
||||||
|
groups.push({ label: folder, icon: folderIcon, files: folderFiles.sort((a, b) => b.filename.localeCompare(a.filename)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ files: AgentFile[] }>(`/agents/${props.agentId}/files`)
|
||||||
|
files.value = data.files
|
||||||
|
} catch {
|
||||||
|
files.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFileContent(filename: string) {
|
||||||
|
fileLoading.value = true
|
||||||
|
try {
|
||||||
|
// Für Unterordner-Dateien: URL-Parameter verwenden
|
||||||
|
const encodedName = filename.includes('/')
|
||||||
|
? `_placeholder?path=${encodeURIComponent(filename)}`
|
||||||
|
: encodeURIComponent(filename)
|
||||||
|
const data = await api.get<{ file: AgentFile }>(`/agents/${props.agentId}/files/${encodedName}`)
|
||||||
|
selectedFile.value = data.file
|
||||||
|
fileContent.value = data.file.content || ''
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Datei konnte nicht geladen werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
fileLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFile(file: AgentFile) {
|
||||||
|
loadFileContent(file.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.agentId, () => {
|
||||||
|
selectedFile.value = null
|
||||||
|
fileContent.value = ''
|
||||||
|
loadFiles()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadFiles)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="agent-files">
|
||||||
|
<div class="files-layout">
|
||||||
|
<!-- Left: File list -->
|
||||||
|
<div class="file-list">
|
||||||
|
<h3><i class="pi pi-file"></i> Workspace Dateien</h3>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-small">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="files.length === 0" class="empty">
|
||||||
|
Keine Dateien vorhanden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="file-groups">
|
||||||
|
<div v-for="group in groupedFiles" :key="group.label" class="file-group">
|
||||||
|
<div class="group-header">
|
||||||
|
<span>{{ group.icon }} {{ group.label }}</span>
|
||||||
|
<span class="group-count">{{ group.files.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-items">
|
||||||
|
<div
|
||||||
|
v-for="file in group.files"
|
||||||
|
:key="file._id"
|
||||||
|
class="file-item"
|
||||||
|
:class="{ active: selectedFile?.filename === file.filename }"
|
||||||
|
@click="selectFile(file)"
|
||||||
|
>
|
||||||
|
<span class="file-icon">{{ getFileIcon(file.filename) }}</span>
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-name">{{ getDisplayName(file.filename) }}</span>
|
||||||
|
<span class="file-date">{{ formatDate(file.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Content viewer -->
|
||||||
|
<div class="file-content">
|
||||||
|
<template v-if="fileLoading">
|
||||||
|
<div class="loading-center">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="selectedFile">
|
||||||
|
<div class="content-header">
|
||||||
|
<span>{{ getFileIcon(selectedFile.filename) }} {{ selectedFile.filename }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="content-body">
|
||||||
|
<pre class="markdown-raw"><code>{{ fileContent }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="empty-center">
|
||||||
|
<i class="pi pi-file" style="font-size: 3rem; opacity: 0.3;"></i>
|
||||||
|
<p>Datei auswählen</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agent-files {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
height: 500px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-count {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.active {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e6edf3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-date {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
background: #0d1117;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
background: #161b22;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #e6edf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-raw {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-raw code {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-small {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-center, .empty-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.files-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
max-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-body {
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
437
src/components/AttachmentList.vue
Normal file
437
src/components/AttachmentList.vue
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
_id: string
|
||||||
|
originalName: string
|
||||||
|
mimeType: string
|
||||||
|
size: number
|
||||||
|
createdAt: string
|
||||||
|
data?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
parentType: 'task' | 'comment' | 'project' | 'agent'
|
||||||
|
parentId: string
|
||||||
|
enablePaste?: boolean
|
||||||
|
}>(), {
|
||||||
|
enablePaste: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const attachments = ref<Attachment[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
// Lightbox state
|
||||||
|
const showLightbox = ref(false)
|
||||||
|
const lightboxImage = ref('')
|
||||||
|
const lightboxName = ref('')
|
||||||
|
|
||||||
|
async function loadAttachments() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ attachments: Attachment[] }>(`/attachments/${props.parentType}/${props.parentId}`)
|
||||||
|
attachments.value = res.attachments
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load attachments:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
if (!input.files?.length) return
|
||||||
|
|
||||||
|
const file = input.files[0]
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Zu groß', detail: 'Max. 5MB erlaubt', life: 5000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async () => {
|
||||||
|
const base64 = (reader.result as string).split(',')[1]
|
||||||
|
await api.post(`/attachments/${props.parentType}/${props.parentId}`, {
|
||||||
|
filename: file.name,
|
||||||
|
data: base64,
|
||||||
|
mimeType: file.type
|
||||||
|
})
|
||||||
|
await loadAttachments()
|
||||||
|
emit('update')
|
||||||
|
toast.add({ severity: 'success', summary: 'Hochgeladen', detail: file.name, life: 3000 })
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste handler for clipboard images
|
||||||
|
async function handlePaste(event: ClipboardEvent) {
|
||||||
|
const items = event.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
event.preventDefault()
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Zu groß', detail: 'Max. 5MB erlaubt', life: 5000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
const filename = `clipboard-${timestamp}.png`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async () => {
|
||||||
|
const base64 = (reader.result as string).split(',')[1]
|
||||||
|
await api.post(`/attachments/${props.parentType}/${props.parentId}`, {
|
||||||
|
filename,
|
||||||
|
data: base64,
|
||||||
|
mimeType: file.type || 'image/png'
|
||||||
|
})
|
||||||
|
await loadAttachments()
|
||||||
|
emit('update')
|
||||||
|
toast.add({ severity: 'success', summary: 'Eingefügt', detail: 'Bild aus Zwischenablage', life: 3000 })
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAttachment(id: string, name: string) {
|
||||||
|
if (!confirm(`"${name}" löschen?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/attachments/${id}`)
|
||||||
|
attachments.value = attachments.value.filter(a => a._id !== id)
|
||||||
|
emit('update')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAttachment(att: Attachment) {
|
||||||
|
// For images, show in lightbox
|
||||||
|
if (att.mimeType.startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ attachment: { data: string; mimeType: string } }>(`/attachments/file/${att._id}`)
|
||||||
|
lightboxImage.value = `data:${res.attachment.mimeType};base64,${res.attachment.data}`
|
||||||
|
lightboxName.value = att.originalName
|
||||||
|
showLightbox.value = true
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other files, download
|
||||||
|
await downloadAttachment(att._id, att.originalName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAttachment(id: string, name: string) {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ attachment: { data: string; mimeType: string } }>(`/attachments/file/${id}`)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = `data:${res.attachment.mimeType};base64,${res.attachment.data}`
|
||||||
|
link.download = name
|
||||||
|
link.click()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
showLightbox.value = false
|
||||||
|
lightboxImage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(mimeType: string) {
|
||||||
|
return mimeType.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAttachments()
|
||||||
|
// Only register paste handler if explicitly enabled (to avoid duplicates)
|
||||||
|
if (props.enablePaste) {
|
||||||
|
document.addEventListener('paste', handlePaste)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (props.enablePaste) {
|
||||||
|
document.removeEventListener('paste', handlePaste)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expose reload for parent components
|
||||||
|
defineExpose({ reload: loadAttachments })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="attachment-list">
|
||||||
|
<div class="attachment-header">
|
||||||
|
<h4><i class="pi pi-paperclip"></i> Anhänge ({{ attachments.length }})</h4>
|
||||||
|
<label class="upload-btn">
|
||||||
|
<input type="file" @change="uploadFile" hidden />
|
||||||
|
<i class="pi pi-upload"></i>
|
||||||
|
{{ uploading ? 'Lädt...' : 'Hochladen' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-small">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="attachments.length === 0" class="empty-text">
|
||||||
|
Keine Anhänge{{ enablePaste ? ' • Strg+V für Zwischenablage' : '' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="attachments-grid">
|
||||||
|
<div
|
||||||
|
v-for="att in attachments"
|
||||||
|
:key="att._id"
|
||||||
|
class="attachment-item"
|
||||||
|
:class="{ 'is-image': isImage(att.mimeType) }"
|
||||||
|
@click="openAttachment(att)"
|
||||||
|
>
|
||||||
|
<div class="att-icon">
|
||||||
|
<i class="pi" :class="isImage(att.mimeType) ? 'pi-image' : 'pi-file'"></i>
|
||||||
|
</div>
|
||||||
|
<div class="att-info">
|
||||||
|
<span class="att-name">{{ att.originalName }}</span>
|
||||||
|
<span class="att-size">{{ formatSize(att.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="att-actions">
|
||||||
|
<button @click.stop="downloadAttachment(att._id, att.originalName)" class="icon-btn" title="Download">
|
||||||
|
<i class="pi pi-download"></i>
|
||||||
|
</button>
|
||||||
|
<button @click.stop="deleteAttachment(att._id, att.originalName)" class="icon-btn delete" title="Löschen">
|
||||||
|
<i class="pi pi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showLightbox" class="lightbox" @click="closeLightbox">
|
||||||
|
<div class="lightbox-content" @click.stop>
|
||||||
|
<button class="lightbox-close" @click="closeLightbox">
|
||||||
|
<i class="pi pi-times"></i>
|
||||||
|
</button>
|
||||||
|
<img :src="lightboxImage" :alt="lightboxName" />
|
||||||
|
<div class="lightbox-caption">{{ lightboxName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.attachment-list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #818cf8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-small {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-item.is-image {
|
||||||
|
border-left: 3px solid #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-name {
|
||||||
|
display: block;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-size {
|
||||||
|
display: block;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
padding: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-caption {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1064
src/components/CommentList.vue
Normal file
1064
src/components/CommentList.vue
Normal file
File diff suppressed because it is too large
Load Diff
134
src/components/SpeechToText.vue
Normal file
134
src/components/SpeechToText.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'transcribed', text: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const recording = ref(false)
|
||||||
|
const transcribing = ref(false)
|
||||||
|
|
||||||
|
let mediaRecorder: MediaRecorder | null = null
|
||||||
|
let audioChunks: Blob[] = []
|
||||||
|
|
||||||
|
async function toggleRecording() {
|
||||||
|
if (recording.value) {
|
||||||
|
stopRecording()
|
||||||
|
} else {
|
||||||
|
await startRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' })
|
||||||
|
audioChunks = []
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) audioChunks.push(event.data)
|
||||||
|
}
|
||||||
|
mediaRecorder.onstop = async () => {
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
|
||||||
|
await transcribeAudio(audioBlob)
|
||||||
|
}
|
||||||
|
mediaRecorder.start()
|
||||||
|
recording.value = true
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Mikrofon-Zugriff verweigert', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
|
||||||
|
recording.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcribeAudio(blob: Blob) {
|
||||||
|
transcribing.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('audio', blob, 'recording.webm')
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
const token = localStorage.getItem('ams_token')
|
||||||
|
const response = await fetch(`${apiBase}/transcribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.error) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Transkription', detail: data.error, life: 5000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.text) {
|
||||||
|
emit('transcribed', data.text)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Transkription fehlgeschlagen', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
transcribing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="stt-btn"
|
||||||
|
:class="{ recording, transcribing }"
|
||||||
|
@click="toggleRecording"
|
||||||
|
:disabled="transcribing"
|
||||||
|
:title="recording ? 'Aufnahme stoppen' : transcribing ? 'Transkribiert...' : 'Spracheingabe'"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<i v-if="transcribing" class="pi pi-spin pi-spinner"></i>
|
||||||
|
<i v-else :class="recording ? 'pi pi-stop-circle' : 'pi pi-microphone'"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stt-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stt-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stt-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stt-btn.recording {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
color: #fca5a5;
|
||||||
|
animation: pulse-glow 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stt-btn.transcribing {
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
261
src/components/TaskChangelog.vue
Normal file
261
src/components/TaskChangelog.vue
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
interface ChangelogEntry {
|
||||||
|
_id: string
|
||||||
|
taskId: string
|
||||||
|
field: string
|
||||||
|
oldValue: unknown
|
||||||
|
newValue: unknown
|
||||||
|
changedBy: string
|
||||||
|
changedByName?: string
|
||||||
|
changedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Label {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: string
|
||||||
|
agents: Agent[]
|
||||||
|
labels: Label[]
|
||||||
|
projects: Project[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const entries = ref<ChangelogEntry[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
backlog: 'Backlog',
|
||||||
|
todo: 'To Do',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
review: 'Review',
|
||||||
|
done: 'Done'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_LABELS: Record<string, string> = {
|
||||||
|
low: 'Niedrig',
|
||||||
|
medium: 'Mittel',
|
||||||
|
high: 'Hoch',
|
||||||
|
urgent: 'Dringend'
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
title: 'Titel',
|
||||||
|
description: 'Beschreibung',
|
||||||
|
status: 'Status',
|
||||||
|
priority: 'Priorität',
|
||||||
|
assignee: 'Agent',
|
||||||
|
project: 'Projekt',
|
||||||
|
labels: 'Labels',
|
||||||
|
dueDate: 'Fälligkeitsdatum',
|
||||||
|
commits: 'GitLab Commit'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFieldName(field: string): string {
|
||||||
|
return FIELD_LABELS[field] || field
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(field: string, value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === '') return '—'
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'status':
|
||||||
|
return STATUS_LABELS[value as string] || (value as string)
|
||||||
|
case 'priority':
|
||||||
|
return PRIORITY_LABELS[value as string] || (value as string)
|
||||||
|
case 'assignee': {
|
||||||
|
const agent = props.agents.find(a => a._id === value)
|
||||||
|
return agent ? `${agent.emoji} ${agent.name}` : (value as string)
|
||||||
|
}
|
||||||
|
case 'project': {
|
||||||
|
const project = props.projects.find(p => p._id === value)
|
||||||
|
return project ? project.name : (value as string)
|
||||||
|
}
|
||||||
|
case 'labels': {
|
||||||
|
const ids = value as string[]
|
||||||
|
if (!ids?.length) return '—'
|
||||||
|
return ids.map(id => {
|
||||||
|
const label = props.labels.find(l => l._id === id)
|
||||||
|
return label ? label.name : id
|
||||||
|
}).join(', ')
|
||||||
|
}
|
||||||
|
case 'dueDate':
|
||||||
|
return value ? new Date(value as string).toLocaleDateString('de-DE') : '—'
|
||||||
|
case 'description':
|
||||||
|
return (value as string).length > 50
|
||||||
|
? (value as string).substring(0, 50) + '…'
|
||||||
|
: (value as string)
|
||||||
|
default:
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserName(entry: ChangelogEntry): string {
|
||||||
|
if (entry.changedByName) return entry.changedByName
|
||||||
|
const agent = props.agents.find(a => a._id === entry.changedBy)
|
||||||
|
if (agent) return `${agent.emoji} ${agent.name}`
|
||||||
|
return entry.changedBy.substring(0, 8) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChangelog() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ changelog: ChangelogEntry[] }>(`/tasks/${props.taskId}/changelog`)
|
||||||
|
entries.value = res.changelog
|
||||||
|
} catch {
|
||||||
|
entries.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadChangelog)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="changelog">
|
||||||
|
<h3><i class="pi pi-history"></i> Änderungslog</h3>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-small">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Lade…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="entries.length === 0" class="empty">
|
||||||
|
Noch keine Änderungen protokolliert.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="changelog-list">
|
||||||
|
<div v-for="entry in entries" :key="entry._id" class="changelog-entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<span class="entry-user">{{ getUserName(entry) }}</span>
|
||||||
|
<span class="entry-date">{{ formatDate(entry.changedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-change">
|
||||||
|
<span class="field-name">{{ formatFieldName(entry.field) }}</span>
|
||||||
|
<span class="old-value">{{ formatValue(entry.field, entry.oldValue) }}</span>
|
||||||
|
<i class="pi pi-arrow-right arrow"></i>
|
||||||
|
<span class="new-value">{{ formatValue(entry.field, entry.newValue) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.changelog {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-small {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changelog-entry {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-user {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-change {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.old-value {
|
||||||
|
color: rgba(239, 68, 68, 0.8);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-value {
|
||||||
|
color: rgba(34, 197, 94, 0.9);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
620
src/components/TaskCommits.vue
Normal file
620
src/components/TaskCommits.vue
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
|
interface TaskCommit {
|
||||||
|
_id: string
|
||||||
|
taskId: string
|
||||||
|
commitSha: string
|
||||||
|
shortId: string
|
||||||
|
gitlabProjectId: number
|
||||||
|
gitlabProjectPath: string
|
||||||
|
commitTitle: string
|
||||||
|
commitAuthor: string
|
||||||
|
commitDate: string
|
||||||
|
commitUrl: string
|
||||||
|
linkedBy: string
|
||||||
|
linkedByName: string
|
||||||
|
linkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLabProject {
|
||||||
|
projectId: number
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLabCommit {
|
||||||
|
id: string
|
||||||
|
shortId: string
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
author: string
|
||||||
|
authorEmail: string
|
||||||
|
date: string
|
||||||
|
webUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
gitlabProjects?: GitLabProject[]
|
||||||
|
gitlabProjectId?: number
|
||||||
|
gitlabPath?: string
|
||||||
|
gitlabUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: string
|
||||||
|
projectId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const commits = ref<TaskCommit[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showLinkDialog = ref(false)
|
||||||
|
|
||||||
|
// Link-Dialog State
|
||||||
|
const availableProjects = ref<GitLabProject[]>([])
|
||||||
|
const selectedGitLabProject = ref<number | null>(null)
|
||||||
|
const gitlabCommits = ref<GitLabCommit[]>([])
|
||||||
|
const loadingCommits = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const commitPage = ref(1)
|
||||||
|
|
||||||
|
const filteredCommits = computed(() => {
|
||||||
|
if (!searchQuery.value) return gitlabCommits.value
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return gitlabCommits.value.filter(c =>
|
||||||
|
c.title.toLowerCase().includes(q) ||
|
||||||
|
c.shortId.toLowerCase().includes(q) ||
|
||||||
|
c.author.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const alreadyLinkedShas = computed(() => new Set(commits.value.map(c => c.commitSha)))
|
||||||
|
|
||||||
|
async function loadCommits() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ commits: TaskCommit[] }>(`/tasks/${props.taskId}/commits`)
|
||||||
|
commits.value = res.commits
|
||||||
|
} catch {
|
||||||
|
commits.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectGitLabInfo() {
|
||||||
|
if (!props.projectId) return
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ projects: Project[] }>('/tasks/projects/list')
|
||||||
|
const project = res.projects.find(p => p._id === props.projectId)
|
||||||
|
if (!project) return
|
||||||
|
|
||||||
|
const projects: GitLabProject[] = []
|
||||||
|
|
||||||
|
// Neue Multi-Projekt-Struktur
|
||||||
|
if (project.gitlabProjects?.length) {
|
||||||
|
projects.push(...project.gitlabProjects)
|
||||||
|
}
|
||||||
|
// Legacy Single-Projekt
|
||||||
|
if (project.gitlabProjectId && !projects.some(p => p.projectId === project.gitlabProjectId)) {
|
||||||
|
projects.push({
|
||||||
|
projectId: project.gitlabProjectId,
|
||||||
|
path: project.gitlabPath || '',
|
||||||
|
url: project.gitlabUrl || '',
|
||||||
|
name: project.gitlabPath?.split('/').pop() || 'GitLab Projekt',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
availableProjects.value = projects
|
||||||
|
if (projects.length === 1) {
|
||||||
|
selectedGitLabProject.value = projects[0].projectId
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
availableProjects.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGitLabCommits() {
|
||||||
|
if (!selectedGitLabProject.value) return
|
||||||
|
loadingCommits.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<GitLabCommit[]>(
|
||||||
|
`/gitlab/projects/${selectedGitLabProject.value}/commits?per_page=50&page=${commitPage.value}`
|
||||||
|
)
|
||||||
|
gitlabCommits.value = res
|
||||||
|
} catch {
|
||||||
|
gitlabCommits.value = []
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'GitLab-Commits konnten nicht geladen werden', life: 5000 })
|
||||||
|
} finally {
|
||||||
|
loadingCommits.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkCommit(commit: GitLabCommit) {
|
||||||
|
const project = availableProjects.value.find(p => p.projectId === selectedGitLabProject.value)
|
||||||
|
try {
|
||||||
|
await api.post(`/tasks/${props.taskId}/commits`, {
|
||||||
|
commitSha: commit.id,
|
||||||
|
shortId: commit.shortId,
|
||||||
|
gitlabProjectId: selectedGitLabProject.value,
|
||||||
|
gitlabProjectPath: project?.path || '',
|
||||||
|
commitTitle: commit.title,
|
||||||
|
commitAuthor: commit.author,
|
||||||
|
commitDate: commit.date,
|
||||||
|
commitUrl: commit.webUrl,
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Verknüpft', detail: `Commit ${commit.shortId} verknüpft`, life: 3000 })
|
||||||
|
await loadCommits()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkCommit(commitLink: TaskCommit) {
|
||||||
|
if (!confirm(`Commit ${commitLink.shortId} entfernen?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/tasks/${props.taskId}/commits/${commitLink._id}`)
|
||||||
|
toast.add({ severity: 'success', summary: 'Entfernt', detail: 'Commit-Verknüpfung entfernt', life: 3000 })
|
||||||
|
await loadCommits()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLinkDialog() {
|
||||||
|
showLinkDialog.value = true
|
||||||
|
searchQuery.value = ''
|
||||||
|
gitlabCommits.value = []
|
||||||
|
commitPage.value = 1
|
||||||
|
if (selectedGitLabProject.value) {
|
||||||
|
loadGitLabCommits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProjectChange() {
|
||||||
|
commitPage.value = 1
|
||||||
|
gitlabCommits.value = []
|
||||||
|
searchQuery.value = ''
|
||||||
|
if (selectedGitLabProject.value) {
|
||||||
|
loadGitLabCommits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCommits()
|
||||||
|
loadProjectGitLabInfo()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="task-commits">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3><i class="pi pi-code"></i> GitLab Commits</h3>
|
||||||
|
<button
|
||||||
|
v-if="availableProjects.length > 0"
|
||||||
|
@click="openLinkDialog"
|
||||||
|
class="add-commit-btn"
|
||||||
|
>
|
||||||
|
<i class="pi pi-plus"></i> Verknüpfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-small">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Lade…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="commits.length === 0" class="empty">
|
||||||
|
Keine Commits verknüpft.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="commits-list">
|
||||||
|
<div v-for="commit in commits" :key="commit._id" class="commit-entry">
|
||||||
|
<div class="commit-main">
|
||||||
|
<a :href="commit.commitUrl" target="_blank" rel="noopener" class="commit-sha">
|
||||||
|
{{ commit.shortId }}
|
||||||
|
</a>
|
||||||
|
<span class="commit-title">{{ commit.commitTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="commit-meta">
|
||||||
|
<span class="commit-author">{{ commit.commitAuthor }}</span>
|
||||||
|
<span class="commit-date">{{ formatDate(commit.commitDate) }}</span>
|
||||||
|
<span class="commit-project">{{ commit.gitlabProjectPath }}</span>
|
||||||
|
<button @click="unlinkCommit(commit)" class="unlink-btn" title="Entfernen">
|
||||||
|
<i class="pi pi-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Link Dialog -->
|
||||||
|
<div v-if="showLinkDialog" class="dialog-overlay" @click.self="showLinkDialog = false">
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h3><i class="pi pi-link"></i> Commit verknüpfen</h3>
|
||||||
|
<button @click="showLinkDialog = false" class="close-btn">
|
||||||
|
<i class="pi pi-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-body">
|
||||||
|
<!-- GitLab Projekt auswählen -->
|
||||||
|
<div v-if="availableProjects.length > 1" class="form-group">
|
||||||
|
<label>GitLab Projekt</label>
|
||||||
|
<select v-model="selectedGitLabProject" @change="onProjectChange">
|
||||||
|
<option :value="null" disabled>Projekt wählen…</option>
|
||||||
|
<option v-for="p in availableProjects" :key="p.projectId" :value="p.projectId">
|
||||||
|
{{ p.name }} ({{ p.path }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suche -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Suche</label>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Commit-Titel, SHA oder Autor…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Commits-Liste -->
|
||||||
|
<div v-if="loadingCommits" class="loading-small">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Lade Commits…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredCommits.length === 0 && selectedGitLabProject" class="empty">
|
||||||
|
Keine Commits gefunden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="dialog-commits-list">
|
||||||
|
<div
|
||||||
|
v-for="commit in filteredCommits"
|
||||||
|
:key="commit.id"
|
||||||
|
class="dialog-commit-entry"
|
||||||
|
:class="{ linked: alreadyLinkedShas.has(commit.id) }"
|
||||||
|
>
|
||||||
|
<div class="commit-info">
|
||||||
|
<span class="commit-sha-small">{{ commit.shortId }}</span>
|
||||||
|
<span class="commit-title-small">{{ commit.title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="commit-info-meta">
|
||||||
|
<span>{{ commit.author }}</span>
|
||||||
|
<span>{{ formatDate(commit.date) }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!alreadyLinkedShas.has(commit.id)"
|
||||||
|
@click="linkCommit(commit)"
|
||||||
|
class="link-btn"
|
||||||
|
>
|
||||||
|
<i class="pi pi-link"></i>
|
||||||
|
</button>
|
||||||
|
<span v-else class="already-linked">
|
||||||
|
<i class="pi pi-check"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-commits {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-commit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #818cf8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-commit-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-small {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commits-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-entry {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-sha {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #818cf8;
|
||||||
|
text-decoration: none;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-sha:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-project {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlink-btn {
|
||||||
|
padding: 0.2rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlink-btn:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog */
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
padding: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-commits-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-commit-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-commit-entry.linked {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
grid-column: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-sha-small {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #818cf8;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-title-small {
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commit-info-meta {
|
||||||
|
grid-column: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #818cf8;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.already-linked {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
color: rgba(34, 197, 94, 0.6);
|
||||||
|
padding: 0.4rem;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
812
src/components/TaskEditDialog.vue
Normal file
812
src/components/TaskEditDialog.vue
Normal file
@@ -0,0 +1,812 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import MultiSelect from 'primevue/multiselect'
|
||||||
|
import AttachmentList from './AttachmentList.vue'
|
||||||
|
import CommentList from './CommentList.vue'
|
||||||
|
import TaskChangelog from './TaskChangelog.vue'
|
||||||
|
import SpeechToText from './SpeechToText.vue'
|
||||||
|
|
||||||
|
interface Reminder {
|
||||||
|
enabled: boolean
|
||||||
|
datetime: string
|
||||||
|
interval: 'once' | 'daily' | 'hourly' | 'minutes'
|
||||||
|
intervalValue?: number
|
||||||
|
lastNotified?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
_id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
assignee?: string
|
||||||
|
project?: string
|
||||||
|
labels: string[]
|
||||||
|
reminder?: Reminder
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Label {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: string | null
|
||||||
|
visible: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const task = ref<Task | null>(null)
|
||||||
|
const editForm = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'medium',
|
||||||
|
assignee: '',
|
||||||
|
project: '',
|
||||||
|
labels: [] as string[]
|
||||||
|
})
|
||||||
|
const agents = ref<Agent[]>([])
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const labels = ref<Label[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Reminder state
|
||||||
|
const showReminderForm = ref(false)
|
||||||
|
const reminderDate = ref('')
|
||||||
|
const reminderTime = ref('')
|
||||||
|
const reminderInterval = ref<'once' | 'daily' | 'hourly' | 'minutes'>('once')
|
||||||
|
const reminderMinutes = ref(30)
|
||||||
|
const savingReminder = ref(false)
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val: boolean) => emit('update:visible', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'backlog', label: 'Backlog', color: '#64748b' },
|
||||||
|
{ value: 'todo', label: 'To Do', color: '#3b82f6' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress', color: '#f59e0b' },
|
||||||
|
{ value: 'review', label: 'Review', color: '#8b5cf6' },
|
||||||
|
{ value: 'done', label: 'Done', color: '#22c55e' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ value: 'low', label: 'Niedrig', color: '#64748b' },
|
||||||
|
{ value: 'medium', label: 'Mittel', color: '#3b82f6' },
|
||||||
|
{ value: 'high', label: 'Hoch', color: '#f59e0b' },
|
||||||
|
{ value: 'urgent', label: 'Dringend', color: '#ef4444' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const intervalOptions = [
|
||||||
|
{ value: 'once', label: 'Einmalig' },
|
||||||
|
{ value: 'daily', label: 'Täglich' },
|
||||||
|
{ value: 'hourly', label: 'Stündlich' },
|
||||||
|
{ value: 'minutes', label: 'Alle X Minuten' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const hasReminder = computed(() => task.value?.reminder?.enabled)
|
||||||
|
|
||||||
|
const reminderDisplay = computed(() => {
|
||||||
|
if (!task.value?.reminder) return null
|
||||||
|
const r = task.value.reminder
|
||||||
|
const dt = new Date(r.datetime)
|
||||||
|
const dateStr = dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const timeStr = dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
let intervalStr = ''
|
||||||
|
switch (r.interval) {
|
||||||
|
case 'once': intervalStr = 'Einmalig'; break
|
||||||
|
case 'daily': intervalStr = 'Täglich'; break
|
||||||
|
case 'hourly': intervalStr = 'Stündlich'; break
|
||||||
|
case 'minutes': intervalStr = `Alle ${r.intervalValue} Min`; break
|
||||||
|
}
|
||||||
|
|
||||||
|
return { date: dateStr, time: timeStr, interval: intervalStr, enabled: r.enabled }
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOrder = ['backlog', 'todo', 'in_progress', 'review', 'done']
|
||||||
|
|
||||||
|
function getNextStatus(current: string): string | null {
|
||||||
|
const idx = statusOrder.indexOf(current)
|
||||||
|
if (idx < 0 || idx >= statusOrder.length - 1) return null
|
||||||
|
return statusOrder[idx + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatusLabel = computed(() => {
|
||||||
|
const next = getNextStatus(editForm.value.status)
|
||||||
|
if (!next) return null
|
||||||
|
return statusOptions.find(o => o.value === next)?.label || next
|
||||||
|
})
|
||||||
|
|
||||||
|
async function advanceStatus() {
|
||||||
|
if (!props.taskId) return
|
||||||
|
const next = getNextStatus(editForm.value.status)
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put(`/tasks/${props.taskId}`, { status: next })
|
||||||
|
editForm.value.status = next
|
||||||
|
if (task.value) task.value.status = next
|
||||||
|
toast.add({ severity: 'success', summary: 'Status geändert', detail: `→ ${statusOptions.find(o => o.value === next)?.label}`, life: 2000 })
|
||||||
|
emit('saved')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
return statusOptions.find(s => s.value === status)?.color || '#64748b'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityColor(priority: string) {
|
||||||
|
return priorityOptions.find(p => p.value === priority)?.color || '#64748b'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTask() {
|
||||||
|
if (!props.taskId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [taskRes, agentsRes, projectsRes, labelsRes] = await Promise.all([
|
||||||
|
api.get<{ task: Task }>(`/tasks/${props.taskId}`),
|
||||||
|
api.get<{ agents: Agent[] }>('/agents'),
|
||||||
|
api.get<{ projects: Project[] }>('/tasks/projects/list'),
|
||||||
|
api.get<{ labels: Label[] }>('/labels')
|
||||||
|
])
|
||||||
|
task.value = taskRes.task
|
||||||
|
agents.value = agentsRes.agents
|
||||||
|
projects.value = projectsRes.projects
|
||||||
|
labels.value = labelsRes.labels
|
||||||
|
|
||||||
|
editForm.value = {
|
||||||
|
title: taskRes.task.title,
|
||||||
|
description: taskRes.task.description || '',
|
||||||
|
status: taskRes.task.status,
|
||||||
|
priority: taskRes.task.priority,
|
||||||
|
assignee: taskRes.task.assignee || '',
|
||||||
|
project: taskRes.task.project || '',
|
||||||
|
labels: taskRes.task.labels || []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskRes.task.reminder) {
|
||||||
|
const dt = new Date(taskRes.task.reminder.datetime)
|
||||||
|
reminderDate.value = dt.toISOString().split('T')[0]
|
||||||
|
reminderTime.value = dt.toTimeString().slice(0, 5)
|
||||||
|
reminderInterval.value = taskRes.task.reminder.interval
|
||||||
|
reminderMinutes.value = taskRes.task.reminder.intervalValue || 30
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Task nicht gefunden', life: 5000 })
|
||||||
|
emit('update:visible', false)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTask() {
|
||||||
|
if (!props.taskId || !editForm.value.title.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Titel ist erforderlich', life: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await api.put(`/tasks/${props.taskId}`, editForm.value)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Task aktualisiert', life: 3000 })
|
||||||
|
if (task.value) {
|
||||||
|
task.value = { ...task.value, ...editForm.value }
|
||||||
|
}
|
||||||
|
emit('saved')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReminder() {
|
||||||
|
if (!props.taskId || !reminderDate.value || !reminderTime.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Datum und Uhrzeit sind erforderlich', life: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savingReminder.value = true
|
||||||
|
try {
|
||||||
|
const datetime = new Date(`${reminderDate.value}T${reminderTime.value}`)
|
||||||
|
await api.patch(`/tasks/${props.taskId}/reminder`, {
|
||||||
|
datetime: datetime.toISOString(),
|
||||||
|
interval: reminderInterval.value,
|
||||||
|
intervalValue: reminderInterval.value === 'minutes' ? reminderMinutes.value : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (task.value) {
|
||||||
|
task.value.reminder = {
|
||||||
|
enabled: true,
|
||||||
|
datetime: datetime.toISOString(),
|
||||||
|
interval: reminderInterval.value,
|
||||||
|
intervalValue: reminderMinutes.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showReminderForm.value = false
|
||||||
|
toast.add({ severity: 'success', summary: 'Reminder gesetzt', detail: 'Du wirst erinnert', life: 3000 })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
savingReminder.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReminder() {
|
||||||
|
if (!props.taskId) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/tasks/${props.taskId}/reminder`)
|
||||||
|
if (task.value) {
|
||||||
|
task.value.reminder = undefined
|
||||||
|
}
|
||||||
|
toast.add({ severity: 'success', summary: 'Entfernt', detail: 'Reminder gelöscht', life: 3000 })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Löschen fehlgeschlagen'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReminderForm() {
|
||||||
|
if (!reminderDate.value) {
|
||||||
|
const tomorrow = new Date()
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
reminderDate.value = tomorrow.toISOString().split('T')[0]
|
||||||
|
reminderTime.value = '09:00'
|
||||||
|
}
|
||||||
|
showReminderForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.taskId, (newId) => {
|
||||||
|
if (newId && props.visible) {
|
||||||
|
showReminderForm.value = false
|
||||||
|
loadTask()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.visible, (vis) => {
|
||||||
|
if (vis && props.taskId) {
|
||||||
|
showReminderForm.value = false
|
||||||
|
loadTask()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="dialogVisible"
|
||||||
|
:header="task?.title || 'Task bearbeiten'"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '90vw', maxWidth: '1100px' }"
|
||||||
|
:breakpoints="{ '768px': '95vw' }"
|
||||||
|
:contentStyle="{ padding: 0 }"
|
||||||
|
:dismissableMask="true"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="dialog-loading">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="task" class="task-dialog-layout">
|
||||||
|
<!-- LEFT: Edit Form -->
|
||||||
|
<div class="edit-panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Titel</label>
|
||||||
|
<div class="input-with-stt">
|
||||||
|
<input v-model="editForm.title" type="text" placeholder="Task-Titel" />
|
||||||
|
<SpeechToText @transcribed="(t: string) => editForm.title += (editForm.title ? ' ' : '') + t" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<div class="input-with-stt">
|
||||||
|
<textarea v-model="editForm.description" rows="3" placeholder="Beschreibung (optional)"></textarea>
|
||||||
|
<SpeechToText @transcribed="(t: string) => editForm.description += (editForm.description ? ' ' : '') + t" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Status</label>
|
||||||
|
<select v-model="editForm.status">
|
||||||
|
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="status-indicator" :style="{ background: getStatusColor(editForm.status) }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Priorität</label>
|
||||||
|
<select v-model="editForm.priority">
|
||||||
|
<option v-for="opt in priorityOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="status-indicator" :style="{ background: getPriorityColor(editForm.priority) }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Agent</label>
|
||||||
|
<select v-model="editForm.assignee">
|
||||||
|
<option value="">Nicht zugewiesen</option>
|
||||||
|
<option v-for="agent in agents" :key="agent._id" :value="agent._id">
|
||||||
|
{{ agent.emoji }} {{ agent.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Projekt</label>
|
||||||
|
<select v-model="editForm.project">
|
||||||
|
<option value="">Kein Projekt</option>
|
||||||
|
<option v-for="project in projects" :key="project._id" :value="project._id">
|
||||||
|
{{ project.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Labels</label>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="editForm.labels"
|
||||||
|
:options="labels"
|
||||||
|
optionLabel="name"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="Labels auswählen"
|
||||||
|
:maxSelectedLabels="5"
|
||||||
|
class="w-full"
|
||||||
|
display="chip"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="saveTask" class="save-btn" :disabled="saving">
|
||||||
|
<i class="pi" :class="saving ? 'pi-spin pi-spinner' : 'pi-check'"></i>
|
||||||
|
{{ saving ? 'Speichert...' : 'Änderungen speichern' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Quick Action: nur Nächster Status (kein Nächster Task) -->
|
||||||
|
<div v-if="nextStatusLabel" class="quick-actions">
|
||||||
|
<button @click="advanceStatus" class="quick-btn next-status">
|
||||||
|
<i class="pi pi-arrow-right"></i>
|
||||||
|
→ {{ nextStatusLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reminder Section -->
|
||||||
|
<div class="reminder-section">
|
||||||
|
<h3><i class="pi pi-bell"></i> Reminder</h3>
|
||||||
|
|
||||||
|
<div v-if="hasReminder && reminderDisplay" class="reminder-display">
|
||||||
|
<div class="reminder-info">
|
||||||
|
<span class="reminder-datetime">
|
||||||
|
<i class="pi pi-calendar"></i> {{ reminderDisplay.date }}
|
||||||
|
<i class="pi pi-clock"></i> {{ reminderDisplay.time }}
|
||||||
|
</span>
|
||||||
|
<span class="reminder-interval">{{ reminderDisplay.interval }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reminder-actions">
|
||||||
|
<button @click="openReminderForm" class="icon-btn" title="Bearbeiten">
|
||||||
|
<i class="pi pi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="deleteReminder" class="icon-btn delete" title="Löschen">
|
||||||
|
<i class="pi pi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!showReminderForm" class="no-reminder">
|
||||||
|
<button @click="openReminderForm" class="add-reminder-btn">
|
||||||
|
<i class="pi pi-plus"></i> Reminder hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showReminderForm" class="reminder-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Datum</label>
|
||||||
|
<input v-model="reminderDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Uhrzeit</label>
|
||||||
|
<input v-model="reminderTime" type="time" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Wiederholung</label>
|
||||||
|
<select v-model="reminderInterval">
|
||||||
|
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reminderInterval === 'minutes'" class="form-group">
|
||||||
|
<label>Alle X Minuten</label>
|
||||||
|
<input v-model.number="reminderMinutes" type="number" min="1" max="1440" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reminder-form-actions">
|
||||||
|
<button @click="showReminderForm = false" class="cancel-btn">Abbrechen</button>
|
||||||
|
<button @click="saveReminder" class="save-reminder-btn" :disabled="savingReminder">
|
||||||
|
<i class="pi" :class="savingReminder ? 'pi-spin pi-spinner' : 'pi-bell'"></i>
|
||||||
|
{{ savingReminder ? 'Speichert...' : 'Reminder setzen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachments -->
|
||||||
|
<div v-if="taskId" class="attachments-section">
|
||||||
|
<AttachmentList parentType="task" :parentId="taskId" :enablePaste="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Comments + Changelog -->
|
||||||
|
<div v-if="taskId" class="comments-panel">
|
||||||
|
<CommentList parentType="task" :parentId="taskId" />
|
||||||
|
<TaskChangelog
|
||||||
|
:taskId="taskId"
|
||||||
|
:agents="agents"
|
||||||
|
:labels="labels"
|
||||||
|
:projects="projects"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-dialog-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
max-height: 75vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-dialog-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit Panel */
|
||||||
|
.edit-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-stt {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-stt input,
|
||||||
|
.input-with-stt textarea {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 34px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Actions */
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn.next-status {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn.next-status:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reminder */
|
||||||
|
.reminder-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-section h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-datetime {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-datetime i {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-interval {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-reminder {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-reminder-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px dashed rgba(99, 102, 241, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #818cf8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-reminder-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-form {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-reminder-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-reminder-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachments */
|
||||||
|
.attachments-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments Panel */
|
||||||
|
.comments-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon buttons */
|
||||||
|
.icon-btn {
|
||||||
|
padding: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels - using PrimeVue MultiSelect */
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
.task-dialog-layout::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.task-dialog-layout::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
246
src/components/WsStatsPanel.vue
Normal file
246
src/components/WsStatsPanel.vue
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useWebSocketStore } from '../stores/websocket'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
|
||||||
|
interface ServerStats {
|
||||||
|
startedAt: string
|
||||||
|
totalConnections: number
|
||||||
|
totalDisconnections: number
|
||||||
|
totalBroadcasts: number
|
||||||
|
totalMessagesSent: number
|
||||||
|
totalMessagesReceived: number
|
||||||
|
currentConnections: number
|
||||||
|
users: Array<{
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
connectedAt: string
|
||||||
|
messagesSent: number
|
||||||
|
messagesReceived: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverStats = ref<ServerStats | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const uptime = computed(() => {
|
||||||
|
const connAt = wsStore.stats.connectedAt
|
||||||
|
if (!connAt) return '—'
|
||||||
|
const diff = Date.now() - new Date(connAt).getTime()
|
||||||
|
const secs = Math.floor(diff / 1000)
|
||||||
|
if (secs < 60) return `${secs}s`
|
||||||
|
const mins = Math.floor(secs / 60)
|
||||||
|
if (mins < 60) return `${mins}m ${secs % 60}s`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
return `${hrs}h ${mins % 60}m`
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverUptime = computed(() => {
|
||||||
|
if (!serverStats.value?.startedAt) return '—'
|
||||||
|
const diff = Date.now() - new Date(serverStats.value.startedAt).getTime()
|
||||||
|
const secs = Math.floor(diff / 1000)
|
||||||
|
if (secs < 60) return `${secs}s`
|
||||||
|
const mins = Math.floor(secs / 60)
|
||||||
|
if (mins < 60) return `${mins}m`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ${mins % 60}m`
|
||||||
|
const days = Math.floor(hrs / 24)
|
||||||
|
return `${days}d ${hrs % 24}h`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchServerStats() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/ws/status')
|
||||||
|
serverStats.value = res.data
|
||||||
|
} catch {
|
||||||
|
serverStats.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchServerStats()
|
||||||
|
refreshTimer = setInterval(fetchServerStats, 10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ws-stats">
|
||||||
|
<div class="stats-header">
|
||||||
|
<span class="status-dot" :class="{ online: wsStore.isConnected }">●</span>
|
||||||
|
<span class="status-text">{{ wsStore.isConnected ? 'Verbunden' : 'Getrennt' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-section">
|
||||||
|
<h4>Deine Session</h4>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Verbunden seit</span>
|
||||||
|
<span class="stat-value">{{ uptime }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Events empfangen</span>
|
||||||
|
<span class="stat-value">{{ wsStore.stats.eventsReceived }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Events gesendet</span>
|
||||||
|
<span class="stat-value">{{ wsStore.stats.eventsSent }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Reconnects</span>
|
||||||
|
<span class="stat-value">{{ Math.max(0, wsStore.stats.reconnects) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item" v-if="wsStore.stats.lastEventType">
|
||||||
|
<span class="stat-label">Letztes Event</span>
|
||||||
|
<span class="stat-value mono">{{ wsStore.stats.lastEventType }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-section" v-if="serverStats">
|
||||||
|
<h4>Server</h4>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Uptime</span>
|
||||||
|
<span class="stat-value">{{ serverUptime }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Verbindungen aktiv</span>
|
||||||
|
<span class="stat-value">{{ serverStats.currentConnections }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Verbindungen gesamt</span>
|
||||||
|
<span class="stat-value">{{ serverStats.totalConnections }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Broadcasts</span>
|
||||||
|
<span class="stat-value">{{ serverStats.totalBroadcasts }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Nachrichten gesendet</span>
|
||||||
|
<span class="stat-value">{{ serverStats.totalMessagesSent }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Nachrichten empfangen</span>
|
||||||
|
<span class="stat-value">{{ serverStats.totalMessagesReceived }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connected-users" v-if="serverStats.users.length">
|
||||||
|
<h4>Verbundene User</h4>
|
||||||
|
<div class="user-row" v-for="user in serverStats.users" :key="user.userId">
|
||||||
|
<span class="user-dot">●</span>
|
||||||
|
<span class="user-name">{{ user.username }}</span>
|
||||||
|
<span class="user-msgs">↑{{ user.messagesSent }} ↓{{ user.messagesReceived }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ws-stats {
|
||||||
|
padding: 1rem;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
color: rgba(239, 68, 68, 0.8);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
color: rgba(34, 197, 94, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section h4 {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.mono {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected-users {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dot {
|
||||||
|
color: rgba(34, 197, 94, 0.8);
|
||||||
|
font-size: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-msgs {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
src/components/base/BaseButton.vue
Normal file
38
src/components/base/BaseButton.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string
|
||||||
|
icon?: string
|
||||||
|
severity?: 'primary' | 'secondary' | 'success' | 'info' | 'warn' | 'danger' | 'help' | 'contrast'
|
||||||
|
variant?: 'outlined' | 'text'
|
||||||
|
size?: 'small' | 'large'
|
||||||
|
loading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
badge?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
severity: 'primary',
|
||||||
|
loading: false,
|
||||||
|
disabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: [event: MouseEvent]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
:label="label"
|
||||||
|
:icon="icon"
|
||||||
|
:severity="severity"
|
||||||
|
:variant="variant"
|
||||||
|
:size="size"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="disabled"
|
||||||
|
:badge="badge"
|
||||||
|
@click="$emit('click', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
63
src/components/base/BaseCard.vue
Normal file
63
src/components/base/BaseCard.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="base-card">
|
||||||
|
<div v-if="title || $slots.header" class="base-card-header">
|
||||||
|
<slot name="header">
|
||||||
|
<h3 v-if="title" class="base-card-title">{{ title }}</h3>
|
||||||
|
<p v-if="subtitle" class="base-card-subtitle">{{ subtitle }}</p>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="base-card-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.footer" class="base-card-footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-card-header {
|
||||||
|
padding: 1rem 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-card-subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-card-body {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-card-footer {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
src/components/base/BaseInput.vue
Normal file
61
src/components/base/BaseInput.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string | number
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
error?: string
|
||||||
|
disabled?: boolean
|
||||||
|
type?: 'text' | 'email' | 'password' | 'number' | 'search'
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
type: 'text',
|
||||||
|
disabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: string | number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="base-field">
|
||||||
|
<label v-if="label" class="base-label">{{ label }}</label>
|
||||||
|
<InputText
|
||||||
|
:model-value="String(modelValue)"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:type="type"
|
||||||
|
:class="{ 'p-invalid': !!error }"
|
||||||
|
class="base-input"
|
||||||
|
@update:model-value="$emit('update:modelValue', type === 'number' ? Number($event) : $event)"
|
||||||
|
/>
|
||||||
|
<small v-if="error" class="base-error">{{ error }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
src/components/base/BaseModal.vue
Normal file
45
src/components/base/BaseModal.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean
|
||||||
|
header?: string
|
||||||
|
modal?: boolean
|
||||||
|
closable?: boolean
|
||||||
|
draggable?: boolean
|
||||||
|
maximizable?: boolean
|
||||||
|
style?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
modal: true,
|
||||||
|
closable: true,
|
||||||
|
draggable: false,
|
||||||
|
maximizable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
:header="header"
|
||||||
|
:modal="modal"
|
||||||
|
:closable="closable"
|
||||||
|
:draggable="draggable"
|
||||||
|
:maximizable="maximizable"
|
||||||
|
:style="style"
|
||||||
|
@update:visible="$emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<template v-if="$slots.header" #header>
|
||||||
|
<slot name="header" />
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
<template v-if="$slots.footer" #footer>
|
||||||
|
<slot name="footer" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
71
src/components/base/BaseSelect.vue
Normal file
71
src/components/base/BaseSelect.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Select from 'primevue/select'
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string | number | null
|
||||||
|
options: SelectOption[]
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
error?: string
|
||||||
|
disabled?: boolean
|
||||||
|
optionLabel?: string
|
||||||
|
optionValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: null,
|
||||||
|
disabled: false,
|
||||||
|
optionLabel: 'label',
|
||||||
|
optionValue: 'value'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: string | number | null]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="base-field">
|
||||||
|
<label v-if="label" class="base-label">{{ label }}</label>
|
||||||
|
<Select
|
||||||
|
:model-value="modelValue"
|
||||||
|
:options="options"
|
||||||
|
:option-label="optionLabel"
|
||||||
|
:option-value="optionValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="{ 'p-invalid': !!error }"
|
||||||
|
class="base-select"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
<small v-if="error" class="base-error">{{ error }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/components/base/BaseTextarea.vue
Normal file
64
src/components/base/BaseTextarea.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Textarea from 'primevue/textarea'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
error?: string
|
||||||
|
disabled?: boolean
|
||||||
|
rows?: number
|
||||||
|
autoResize?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
disabled: false,
|
||||||
|
rows: 3,
|
||||||
|
autoResize: false
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="base-field">
|
||||||
|
<label v-if="label" class="base-label">{{ label }}</label>
|
||||||
|
<Textarea
|
||||||
|
:model-value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:rows="rows"
|
||||||
|
:auto-resize="autoResize"
|
||||||
|
:class="{ 'p-invalid': !!error }"
|
||||||
|
class="base-textarea"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
<small v-if="error" class="base-error">{{ error }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
6
src/components/base/index.ts
Normal file
6
src/components/base/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as BaseInput } from './BaseInput.vue'
|
||||||
|
export { default as BaseSelect } from './BaseSelect.vue'
|
||||||
|
export { default as BaseTextarea } from './BaseTextarea.vue'
|
||||||
|
export { default as BaseButton } from './BaseButton.vue'
|
||||||
|
export { default as BaseModal } from './BaseModal.vue'
|
||||||
|
export { default as BaseCard } from './BaseCard.vue'
|
||||||
296
src/layouts/AppLayout.vue
Normal file
296
src/layouts/AppLayout.vue
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useWebSocketStore } from '../stores/websocket'
|
||||||
|
import Popover from 'primevue/popover'
|
||||||
|
import WsStatsPanel from '../components/WsStatsPanel.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
const wsPopover = ref()
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
|
// Close sidebar on route change (mobile)
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
sidebarOpen.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const feVersion = import.meta.env.VITE_APP_VERSION || 'dev'
|
||||||
|
const beVersion = ref('...')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
const res = await fetch(`${apiBase}/version`)
|
||||||
|
const data = await res.json()
|
||||||
|
beVersion.value = data.version || 'unknown'
|
||||||
|
} catch {
|
||||||
|
beVersion.value = '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
wsStore.connect()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
wsStore.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
wsStore.disconnect()
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWsStats(event: Event) {
|
||||||
|
wsPopover.value?.toggle(event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-layout">
|
||||||
|
<!-- Mobile Hamburger -->
|
||||||
|
<button class="hamburger" @click="sidebarOpen = !sidebarOpen" :class="{ open: sidebarOpen }">
|
||||||
|
<i :class="sidebarOpen ? 'pi pi-times' : 'pi pi-bars'"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
|
||||||
|
|
||||||
|
<aside class="sidebar" :class="{ open: sidebarOpen }">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>🤖 AMS</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav-container">
|
||||||
|
<ul class="sidebar-nav">
|
||||||
|
<li>
|
||||||
|
<router-link to="/">
|
||||||
|
<i class="pi pi-home"></i>
|
||||||
|
Dashboard
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/agents">
|
||||||
|
<i class="pi pi-users"></i>
|
||||||
|
Agents
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/projects">
|
||||||
|
<i class="pi pi-folder"></i>
|
||||||
|
Projekte
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/tasks">
|
||||||
|
<i class="pi pi-list-check"></i>
|
||||||
|
Tasks
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/logs">
|
||||||
|
<i class="pi pi-file"></i>
|
||||||
|
Logs
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/labels">
|
||||||
|
<i class="pi pi-tags"></i>
|
||||||
|
Labels
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/gitlab">
|
||||||
|
<i class="pi pi-github"></i>
|
||||||
|
GitLab
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/agent-task">
|
||||||
|
<i class="pi pi-send"></i>
|
||||||
|
Aufgabe stellen
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/agent-tasks-overview">
|
||||||
|
<i class="pi pi-list-check"></i>
|
||||||
|
Agent-Aufträge
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/cronjobs">
|
||||||
|
<i class="pi pi-clock"></i>
|
||||||
|
CronJobs
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/workspace">
|
||||||
|
<i class="pi pi-folder-open"></i>
|
||||||
|
Agent-Dateien
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/secrets">
|
||||||
|
<i class="pi pi-lock"></i>
|
||||||
|
Secrets
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/token-analytics">
|
||||||
|
<i class="pi pi-chart-line"></i>
|
||||||
|
Token Analytics
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li v-if="authStore.user?.role === 'admin'">
|
||||||
|
<router-link to="/containers">
|
||||||
|
<i class="pi pi-box"></i>
|
||||||
|
Container
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link to="/settings">
|
||||||
|
<i class="pi pi-cog"></i>
|
||||||
|
Einstellungen
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div style="color: rgba(255,255,255,0.5); font-size: 0.875rem; margin-bottom: 0.5rem;">
|
||||||
|
{{ authStore.user?.username }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleLogout"
|
||||||
|
style="background: none; border: 1px solid rgba(255,255,255,0.2); color: rgba(255,255,255,0.7); padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; width: 100%;"
|
||||||
|
>
|
||||||
|
<i class="pi pi-sign-out" style="margin-right: 0.5rem;"></i>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
<div class="sidebar-version">
|
||||||
|
<span class="ws-indicator" :class="{ online: wsStore.isConnected }" :title="wsStore.isConnected ? 'WebSocket verbunden' : 'WebSocket getrennt'" @click="toggleWsStats" style="cursor: pointer;">●</span>
|
||||||
|
FE {{ feVersion }} | BE {{ beVersion }}
|
||||||
|
</div>
|
||||||
|
<Popover ref="wsPopover">
|
||||||
|
<WsStatsPanel />
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hamburger {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 1100;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(26, 26, 46, 0.95);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hamburger {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 999;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
width: 220px;
|
||||||
|
background: #0f0f1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
padding-top: 3.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-version {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-indicator {
|
||||||
|
color: rgba(239, 68, 68, 0.6);
|
||||||
|
font-size: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-indicator.online {
|
||||||
|
color: rgba(34, 197, 94, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popover Dark Theme */
|
||||||
|
:deep(.p-popover) {
|
||||||
|
background: #1a1a2e !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-popover-content) {
|
||||||
|
background: #1a1a2e !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-popover)::before,
|
||||||
|
:deep(.p-popover)::after {
|
||||||
|
border-color: transparent !important;
|
||||||
|
border-bottom-color: #1a1a2e !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
src/main.ts
Normal file
29
src/main.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import Aura from '@primevue/themes/aura'
|
||||||
|
import ToastService from 'primevue/toastservice'
|
||||||
|
import ConfirmationService from 'primevue/confirmationservice'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
import 'primeicons/primeicons.css'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(PrimeVue, {
|
||||||
|
theme: {
|
||||||
|
preset: Aura,
|
||||||
|
options: {
|
||||||
|
darkModeSelector: '.dark-mode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
app.use(ToastService)
|
||||||
|
app.use(ConfirmationService)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
121
src/router/index.ts
Normal file
121
src/router/index.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
meta: { requiresGuest: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'register',
|
||||||
|
component: () => import('../views/RegisterView.vue'),
|
||||||
|
meta: { requiresGuest: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('../layouts/AppLayout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('../views/DashboardView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'agents',
|
||||||
|
name: 'agents',
|
||||||
|
component: () => import('../views/AgentsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tasks',
|
||||||
|
name: 'tasks',
|
||||||
|
component: () => import('../views/TasksView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tasks/:id',
|
||||||
|
name: 'task-detail',
|
||||||
|
component: () => import('../views/TaskDetailView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'projects',
|
||||||
|
name: 'projects',
|
||||||
|
component: () => import('../views/ProjectsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'logs',
|
||||||
|
name: 'logs',
|
||||||
|
component: () => import('../views/LogsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'labels',
|
||||||
|
name: 'labels',
|
||||||
|
component: () => import('../views/LabelsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('../views/SettingsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gitlab',
|
||||||
|
name: 'gitlab',
|
||||||
|
component: () => import('../views/GitLabView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'containers',
|
||||||
|
name: 'containers',
|
||||||
|
component: () => import('../views/ContainersView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'agent-task',
|
||||||
|
name: 'agent-task',
|
||||||
|
component: () => import('../views/AgentTaskView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'workspace',
|
||||||
|
name: 'workspace',
|
||||||
|
component: () => import('../views/WorkspaceView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'agent-tasks-overview',
|
||||||
|
name: 'agent-tasks-overview',
|
||||||
|
component: () => import('../views/AgentTasksOverview.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cronjobs',
|
||||||
|
name: 'cronjobs',
|
||||||
|
component: () => import('../views/CronJobsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'secrets',
|
||||||
|
name: 'secrets',
|
||||||
|
component: () => import('../views/SecretsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'token-analytics',
|
||||||
|
name: 'token-analytics',
|
||||||
|
component: () => import('../views/TokenAnalyticsView.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
|
||||||
|
next('/')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
69
src/stores/auth.ts
Normal file
69
src/stores/auth.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(null)
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
function loadFromStorage() {
|
||||||
|
const storedToken = localStorage.getItem('ams_token')
|
||||||
|
const storedUser = localStorage.getItem('ams_user')
|
||||||
|
|
||||||
|
if (storedToken && storedUser) {
|
||||||
|
token.value = storedToken
|
||||||
|
user.value = JSON.parse(storedUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToStorage() {
|
||||||
|
if (token.value && user.value) {
|
||||||
|
localStorage.setItem('ams_token', token.value)
|
||||||
|
localStorage.setItem('ams_user', JSON.stringify(user.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStorage() {
|
||||||
|
localStorage.removeItem('ams_token')
|
||||||
|
localStorage.removeItem('ams_user')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
const response = await api.post('/auth/login', { email, password })
|
||||||
|
token.value = response.token
|
||||||
|
user.value = response.user
|
||||||
|
saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(email: string, username: string, password: string) {
|
||||||
|
const response = await api.post('/auth/register', { email, username, password })
|
||||||
|
token.value = response.token
|
||||||
|
user.value = response.user
|
||||||
|
saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = null
|
||||||
|
user.value = null
|
||||||
|
clearStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
loadFromStorage,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout
|
||||||
|
}
|
||||||
|
})
|
||||||
161
src/stores/websocket.ts
Normal file
161
src/stores/websocket.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
interface WsMessage {
|
||||||
|
event: string
|
||||||
|
data: unknown
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventHandler = (data: unknown) => void
|
||||||
|
|
||||||
|
export interface WsSessionStats {
|
||||||
|
connectedAt: string | null
|
||||||
|
eventsReceived: number
|
||||||
|
eventsSent: number
|
||||||
|
reconnects: number
|
||||||
|
lastEventType: string | null
|
||||||
|
lastEventTime: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWebSocketStore = defineStore('websocket', () => {
|
||||||
|
const connected = ref(false)
|
||||||
|
const connectedUsers = ref(0)
|
||||||
|
const lastEvent = ref<WsMessage | null>(null)
|
||||||
|
|
||||||
|
// Session statistics
|
||||||
|
const stats = ref<WsSessionStats>({
|
||||||
|
connectedAt: null,
|
||||||
|
eventsReceived: 0,
|
||||||
|
eventsSent: 0,
|
||||||
|
reconnects: -1, // First connect is not a reconnect
|
||||||
|
lastEventType: null,
|
||||||
|
lastEventTime: null
|
||||||
|
})
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let pingTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
const handlers: Map<string, Set<EventHandler>> = new Map()
|
||||||
|
|
||||||
|
const isConnected = computed(() => connected.value)
|
||||||
|
|
||||||
|
function getWsUrl(): string {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
return apiUrl
|
||||||
|
.replace(/^https:/, 'wss:')
|
||||||
|
.replace(/^http:/, 'ws:')
|
||||||
|
.replace(/\/api$/, '/ws')
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(): void {
|
||||||
|
const token = localStorage.getItem('ams_token')
|
||||||
|
if (!token || ws?.readyState === WebSocket.OPEN) return
|
||||||
|
|
||||||
|
const url = `${getWsUrl()}?token=${token}`
|
||||||
|
stats.value.reconnects++
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
} catch {
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
stats.value.connectedAt = new Date().toISOString()
|
||||||
|
pingTimer = setInterval(() => {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'ping' }))
|
||||||
|
stats.value.eventsSent++
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg: WsMessage = JSON.parse(e.data)
|
||||||
|
lastEvent.value = msg
|
||||||
|
stats.value.eventsReceived++
|
||||||
|
stats.value.lastEventType = msg.event
|
||||||
|
stats.value.lastEventTime = msg.timestamp || new Date().toISOString()
|
||||||
|
|
||||||
|
if (msg.event === 'connected') {
|
||||||
|
const data = msg.data as { connectedUsers: number }
|
||||||
|
connectedUsers.value = data.connectedUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.get(msg.event)?.forEach((handler) => {
|
||||||
|
try { handler(msg.data) } catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
handlers.get('*')?.forEach((handler) => {
|
||||||
|
try { handler(msg) } catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
cleanup()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
cleanup()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect(): void {
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
}
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(): void {
|
||||||
|
connected.value = false
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer)
|
||||||
|
pingTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(): void {
|
||||||
|
if (reconnectTimer) return
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null
|
||||||
|
const token = localStorage.getItem('ams_token')
|
||||||
|
if (token) connect()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function on(event: string, handler: EventHandler): void {
|
||||||
|
if (!handlers.has(event)) handlers.set(event, new Set())
|
||||||
|
handlers.get(event)!.add(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function off(event: string, handler: EventHandler): void {
|
||||||
|
handlers.get(event)?.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
connectedUsers,
|
||||||
|
lastEvent,
|
||||||
|
stats,
|
||||||
|
isConnected,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
on,
|
||||||
|
off
|
||||||
|
}
|
||||||
|
})
|
||||||
475
src/style.css
Normal file
475
src/style.css
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input,
|
||||||
|
.form-field select,
|
||||||
|
.form-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input:focus,
|
||||||
|
.form-field select:focus,
|
||||||
|
.form-field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input::placeholder,
|
||||||
|
.form-field textarea::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix select dropdown colors */
|
||||||
|
.form-field select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-mode {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-mode a {
|
||||||
|
color: #8b5cf6;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-mode a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Layout */
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 130px;
|
||||||
|
min-width: 130px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.75rem;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a:hover,
|
||||||
|
.sidebar-nav a.router-link-active {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 130px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PrimeVue Popover Dark Theme */
|
||||||
|
/* Dialog Dark Theme (global, PrimeVue Dialoge werden per Teleport ins body gerendert) */
|
||||||
|
.p-dialog { background: #1a1a2e !important; border: 1px solid rgba(255, 255, 255, 0.15) !important; color: #fff !important; }
|
||||||
|
.p-dialog-header { background: #1a1a2e !important; color: #fff !important; border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important; padding: 1.25rem 1.5rem !important; }
|
||||||
|
.p-dialog-content { background: #1a1a2e !important; color: #fff !important; padding: 1.25rem 1.5rem !important; }
|
||||||
|
.p-dialog-footer { background: #1a1a2e !important; border-top: 1px solid rgba(255, 255, 255, 0.1) !important; padding: 1rem 1.5rem !important; }
|
||||||
|
.p-dialog-header-close { color: rgba(255, 255, 255, 0.6) !important; }
|
||||||
|
.p-dialog-header-close:hover { color: #fff !important; background: rgba(255, 255, 255, 0.1) !important; }
|
||||||
|
|
||||||
|
/* PrimeVue Form Inputs Dark Theme (global für Dialog-Teleport) */
|
||||||
|
.p-dialog .p-inputtext,
|
||||||
|
.p-dialog .p-textarea,
|
||||||
|
.p-dialog .p-password input,
|
||||||
|
.p-dialog input.p-inputtext {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-inputtext:focus,
|
||||||
|
.p-dialog .p-textarea:focus,
|
||||||
|
.p-dialog .p-password input:focus,
|
||||||
|
.p-dialog input.p-inputtext:focus {
|
||||||
|
border-color: #6366f1 !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2) !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-inputtext::placeholder,
|
||||||
|
.p-dialog .p-textarea::placeholder,
|
||||||
|
.p-dialog .p-password input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.35) !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-select,
|
||||||
|
.p-dialog .p-multiselect {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-select:hover,
|
||||||
|
.p-dialog .p-multiselect:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3) !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-select.p-focus,
|
||||||
|
.p-dialog .p-multiselect.p-focus {
|
||||||
|
border-color: #6366f1 !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2) !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-select-label,
|
||||||
|
.p-dialog .p-multiselect-label {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-checkbox .p-checkbox-box {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.25) !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-checkbox .p-checkbox-box.p-highlight {
|
||||||
|
background: #6366f1 !important;
|
||||||
|
border-color: #6366f1 !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-slider {
|
||||||
|
background: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-slider .p-slider-range {
|
||||||
|
background: #6366f1 !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-slider .p-slider-handle {
|
||||||
|
background: #6366f1 !important;
|
||||||
|
border-color: #6366f1 !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-password-toggle-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-password-toggle-btn:hover {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PrimeVue Select/Multiselect Dropdown Panel Dark */
|
||||||
|
.p-select-overlay,
|
||||||
|
.p-multiselect-overlay {
|
||||||
|
background: #1e2235 !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.p-select-option,
|
||||||
|
.p-multiselect-option {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
.p-select-option:hover,
|
||||||
|
.p-multiselect-option:hover,
|
||||||
|
.p-select-option.p-focus,
|
||||||
|
.p-multiselect-option.p-focus {
|
||||||
|
background: rgba(99, 102, 241, 0.2) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.p-select-option.p-highlight,
|
||||||
|
.p-multiselect-option.p-highlight {
|
||||||
|
background: rgba(99, 102, 241, 0.3) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
/* ConfirmDialog Dark */
|
||||||
|
.p-confirmdialog .p-confirmdialog-message {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
.p-confirmdialog .p-confirmdialog-icon {
|
||||||
|
color: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-dialog-footer .p-button,
|
||||||
|
.p-confirmdialog .p-dialog-footer .p-button,
|
||||||
|
.p-confirmdialog-footer .p-button,
|
||||||
|
.p-dialog-footer button.p-button {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.4rem !important;
|
||||||
|
padding: 0.5rem 1rem !important;
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-dialog-footer .p-button:hover,
|
||||||
|
.p-confirmdialog .p-dialog-footer .p-button:hover,
|
||||||
|
.p-dialog-footer button.p-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-dialog-footer .p-button-danger,
|
||||||
|
.p-confirmdialog .p-dialog-footer .p-button-danger,
|
||||||
|
.p-dialog-footer button.p-button-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.15) !important;
|
||||||
|
border-color: rgba(239, 68, 68, 0.3) !important;
|
||||||
|
color: #fca5a5 !important;
|
||||||
|
}
|
||||||
|
.p-dialog .p-dialog-footer .p-button-danger:hover,
|
||||||
|
.p-confirmdialog .p-dialog-footer .p-button-danger:hover,
|
||||||
|
.p-dialog-footer button.p-button-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.3) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-popover {
|
||||||
|
background: #1a1a2e !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-popover-content {
|
||||||
|
background: #1a1a2e !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact sidebar text */
|
||||||
|
.sidebar-nav a {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========= Mobile Responsive ========= */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
width: 220px;
|
||||||
|
min-width: 220px;
|
||||||
|
background: #0f0f1e;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
height: 100dvh;
|
||||||
|
height: -webkit-fill-available;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-top: 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/utils/appUpdate.ts
Normal file
103
src/utils/appUpdate.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Auto-Update Service für die AMS Android App (Capacitor)
|
||||||
|
*
|
||||||
|
* Prüft beim App-Start ob eine neue Version verfügbar ist
|
||||||
|
* und zeigt einen Update-Dialog an.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = 'https://api.ams.agentenbude.de'
|
||||||
|
const APP_VERSION_CODE = 1 // Muss bei jedem Release erhöht werden
|
||||||
|
const APP_VERSION = '1.0.0'
|
||||||
|
|
||||||
|
interface UpdateCheckResponse {
|
||||||
|
updateAvailable: boolean
|
||||||
|
currentVersion: string
|
||||||
|
latestVersion: string
|
||||||
|
latestVersionCode: number
|
||||||
|
downloadUrl: string
|
||||||
|
releaseNotes: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCapacitorApp(): boolean {
|
||||||
|
return !!(window as Record<string, unknown>).Capacitor
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForUpdate(): Promise<UpdateCheckResponse | null> {
|
||||||
|
if (!isCapacitorApp()) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/api/app/update/check?currentVersion=${APP_VERSION}¤tCode=${APP_VERSION_CODE}`
|
||||||
|
)
|
||||||
|
if (!response.ok) return null
|
||||||
|
return await response.json()
|
||||||
|
} catch {
|
||||||
|
console.warn('Update-Check fehlgeschlagen')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showUpdateDialog(update: UpdateCheckResponse): void {
|
||||||
|
const sizeStr = update.size
|
||||||
|
? `(${(update.size / 1024 / 1024).toFixed(1)} MB)`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const dialog = document.createElement('div')
|
||||||
|
dialog.id = 'app-update-dialog'
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<div style="
|
||||||
|
position: fixed; inset: 0; z-index: 99999;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
">
|
||||||
|
<div style="
|
||||||
|
background: #1a1a2e; color: #fff;
|
||||||
|
border-radius: 16px; padding: 24px;
|
||||||
|
max-width: 340px; width: 90%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
">
|
||||||
|
<h3 style="margin: 0 0 8px; font-size: 18px;">🔄 Update verfügbar</h3>
|
||||||
|
<p style="margin: 0 0 12px; color: rgba(255,255,255,0.7); font-size: 14px;">
|
||||||
|
Version <strong>${update.latestVersion}</strong> ist verfügbar ${sizeStr}
|
||||||
|
</p>
|
||||||
|
${update.releaseNotes ? `
|
||||||
|
<p style="margin: 0 0 16px; color: rgba(255,255,255,0.5); font-size: 13px;">
|
||||||
|
${update.releaseNotes}
|
||||||
|
</p>
|
||||||
|
` : ''}
|
||||||
|
<div style="display: flex; gap: 12px;">
|
||||||
|
<button id="update-later" style="
|
||||||
|
flex: 1; padding: 12px; border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
background: transparent; color: #fff; border-radius: 8px;
|
||||||
|
font-size: 14px; cursor: pointer;
|
||||||
|
">Später</button>
|
||||||
|
<button id="update-now" style="
|
||||||
|
flex: 1; padding: 12px; border: none;
|
||||||
|
background: #4ade80; color: #000; border-radius: 8px;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
|
">Jetzt updaten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
document.body.appendChild(dialog)
|
||||||
|
|
||||||
|
document.getElementById('update-later')?.addEventListener('click', () => {
|
||||||
|
dialog.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('update-now')?.addEventListener('click', () => {
|
||||||
|
window.open(update.downloadUrl, '_system')
|
||||||
|
dialog.remove()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initUpdateCheck(): Promise<void> {
|
||||||
|
const update = await checkForUpdate()
|
||||||
|
if (update?.updateAvailable) {
|
||||||
|
showUpdateDialog(update)
|
||||||
|
}
|
||||||
|
}
|
||||||
970
src/views/AgentTaskView.vue
Normal file
970
src/views/AgentTaskView.vue
Normal file
@@ -0,0 +1,970 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { useWebSocketStore } from '../stores/websocket'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import Dropdown from 'primevue/dropdown'
|
||||||
|
import TaskEditDialog from '../components/TaskEditDialog.vue'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
|
||||||
|
// --- Daten ---
|
||||||
|
interface Project {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
_id: string
|
||||||
|
number?: number
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickText {
|
||||||
|
_id: string
|
||||||
|
title: string
|
||||||
|
text: string
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageText = ref('')
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const agents = ref<Agent[]>([])
|
||||||
|
const quickTexts = ref<QuickText[]>([])
|
||||||
|
const selectedProject = ref<string | null>(localStorage.getItem('ams_agent_task_project') || null)
|
||||||
|
const selectedTasks = ref<string[]>([])
|
||||||
|
const selectedAgent = ref<string | null>(localStorage.getItem('ams_agent_task_agent') || null)
|
||||||
|
const sending = ref(false)
|
||||||
|
const recording = ref(false)
|
||||||
|
const transcribing = ref(false)
|
||||||
|
|
||||||
|
// Task-Detail Dialog
|
||||||
|
const taskDialogVisible = ref(false)
|
||||||
|
const taskDialogId = ref<string | null>(null)
|
||||||
|
|
||||||
|
function openTaskDialog(taskId: string) {
|
||||||
|
taskDialogId.value = taskId
|
||||||
|
taskDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskDialogSaved() {
|
||||||
|
// Reload tasks to reflect changes
|
||||||
|
if (selectedProject.value) {
|
||||||
|
loadProjectTasks(selectedProject.value)
|
||||||
|
} else {
|
||||||
|
loadTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick-Text Dialog
|
||||||
|
const quickTextDialog = ref(false)
|
||||||
|
const editingQuickText = ref<QuickText | null>(null)
|
||||||
|
const qtTitle = ref('')
|
||||||
|
const qtText = ref('')
|
||||||
|
|
||||||
|
// MediaRecorder
|
||||||
|
let mediaRecorder: MediaRecorder | null = null
|
||||||
|
let audioChunks: Blob[] = []
|
||||||
|
|
||||||
|
// --- LocalStorage persist ---
|
||||||
|
watch(selectedProject, (val) => {
|
||||||
|
if (val) localStorage.setItem('ams_agent_task_project', val)
|
||||||
|
else localStorage.removeItem('ams_agent_task_project')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedAgent, (val) => {
|
||||||
|
if (val) localStorage.setItem('ams_agent_task_agent', val)
|
||||||
|
else localStorage.removeItem('ams_agent_task_agent')
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Computed ---
|
||||||
|
const filteredTasks = computed(() => {
|
||||||
|
if (!selectedProject.value) return tasks.value
|
||||||
|
return tasks.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectOptions = computed(() =>
|
||||||
|
projects.value.map(p => ({ label: p.name, value: p._id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const agentOptions = computed(() =>
|
||||||
|
agents.value.map(a => ({ label: `${a.emoji} ${a.name}`, value: a._id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- API Calls ---
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ projects: Project[] }>('/tasks/projects/list')
|
||||||
|
projects.value = res.projects || []
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
pending: 'Pending',
|
||||||
|
review: 'Review',
|
||||||
|
done: 'Done',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
backlog: 'Backlog',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: string): string {
|
||||||
|
return statusLabels[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTasks() {
|
||||||
|
tasks.value.sort((a, b) => {
|
||||||
|
const p: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||||
|
return (p[String(a.priority || 'medium')] ?? 4) - (p[String(b.priority || 'medium')] ?? 4)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ tasks: Task[] }>('/tasks')
|
||||||
|
const openStatuses = ['todo', 'pending']
|
||||||
|
tasks.value = (res.tasks || []).filter(t => openStatuses.includes(String(t.status)))
|
||||||
|
sortTasks()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Tasks konnten nicht geladen werden', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ agents: Agent[] }>('/agents')
|
||||||
|
agents.value = res.agents || []
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQuickTexts() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ quickTexts: QuickText[] }>('/quicktexts')
|
||||||
|
quickTexts.value = res.quickTexts || []
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectTasks(projectId: string) {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ tasks: Task[] }>(`/tasks?project=${projectId}`)
|
||||||
|
const openStatuses = ['todo', 'pending']
|
||||||
|
tasks.value = (res.tasks || []).filter(t => openStatuses.includes(String(t.status)))
|
||||||
|
sortTasks()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Tasks konnten nicht geladen werden', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Quick-Texts CRUD ---
|
||||||
|
function openQuickTextDialog(qt?: QuickText) {
|
||||||
|
if (qt) {
|
||||||
|
editingQuickText.value = qt
|
||||||
|
qtTitle.value = qt.title
|
||||||
|
qtText.value = qt.text
|
||||||
|
} else {
|
||||||
|
editingQuickText.value = null
|
||||||
|
qtTitle.value = ''
|
||||||
|
qtText.value = ''
|
||||||
|
}
|
||||||
|
quickTextDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQuickText() {
|
||||||
|
if (!qtTitle.value || !qtText.value) return
|
||||||
|
try {
|
||||||
|
if (editingQuickText.value) {
|
||||||
|
await api.put(`/quicktexts/${editingQuickText.value._id}`, { title: qtTitle.value, text: qtText.value })
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Quick-Text aktualisiert', life: 2000 })
|
||||||
|
} else {
|
||||||
|
await api.post('/quicktexts', { title: qtTitle.value, text: qtText.value })
|
||||||
|
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'Quick-Text hinzugefügt', life: 2000 })
|
||||||
|
}
|
||||||
|
quickTextDialog.value = false
|
||||||
|
await loadQuickTexts()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Speichern fehlgeschlagen', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQuickText(id: string) {
|
||||||
|
try {
|
||||||
|
await api.delete(`/quicktexts/${id}`)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gelöscht', detail: 'Quick-Text entfernt', life: 2000 })
|
||||||
|
await loadQuickTexts()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Löschen fehlgeschlagen', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendQuickText(text: string) {
|
||||||
|
if (messageText.value && !messageText.value.endsWith('\n')) {
|
||||||
|
messageText.value += '\n'
|
||||||
|
}
|
||||||
|
messageText.value += text
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTaskId(taskId: string) {
|
||||||
|
const task = tasks.value.find(t => t._id === taskId)
|
||||||
|
if (task) {
|
||||||
|
const insertText = `[${task.title}] (${taskId})`
|
||||||
|
if (messageText.value && !messageText.value.endsWith('\n')) {
|
||||||
|
messageText.value += '\n'
|
||||||
|
}
|
||||||
|
messageText.value += insertText
|
||||||
|
// Checkbox ebenfalls anhaken
|
||||||
|
if (!selectedTasks.value.includes(taskId)) {
|
||||||
|
selectedTasks.value.push(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTask(taskId: string) {
|
||||||
|
const idx = selectedTasks.value.indexOf(taskId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
selectedTasks.value.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
selectedTasks.value.push(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audio Recording ---
|
||||||
|
async function toggleRecording() {
|
||||||
|
if (recording.value) {
|
||||||
|
stopRecording()
|
||||||
|
} else {
|
||||||
|
await startRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' })
|
||||||
|
audioChunks = []
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) audioChunks.push(event.data)
|
||||||
|
}
|
||||||
|
mediaRecorder.onstop = async () => {
|
||||||
|
stream.getTracks().forEach(track => track.stop())
|
||||||
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' })
|
||||||
|
await transcribeAudio(audioBlob)
|
||||||
|
}
|
||||||
|
mediaRecorder.start()
|
||||||
|
recording.value = true
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Mikrofon-Zugriff verweigert', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
|
||||||
|
recording.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcribeAudio(blob: Blob) {
|
||||||
|
transcribing.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('audio', blob, 'recording.webm')
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
const token = localStorage.getItem('ams_token')
|
||||||
|
const response = await fetch(`${apiBase}/transcribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.error) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Transkription', detail: data.error, life: 5000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.text) {
|
||||||
|
if (messageText.value && !messageText.value.endsWith('\n')) messageText.value += ' '
|
||||||
|
messageText.value += data.text
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Transkription fehlgeschlagen', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
transcribing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!messageText.value.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Nachricht darf nicht leer sein', life: 2000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
// Kontext sammeln
|
||||||
|
const selectedProjectObj = projects.value.find(p => p._id === selectedProject.value)
|
||||||
|
const selectedAgentObj = agents.value.find(a => a._id === selectedAgent.value)
|
||||||
|
const linkedTitles = selectedTasks.value.map(id => tasks.value.find(t => t._id === id)?.title || id)
|
||||||
|
|
||||||
|
// 1. Auftrag in DB speichern
|
||||||
|
await api.post('/agent-tasks', {
|
||||||
|
message: messageText.value.trim(),
|
||||||
|
projectId: selectedProject.value || undefined,
|
||||||
|
projectName: selectedProjectObj?.name || undefined,
|
||||||
|
agentId: selectedAgent.value || undefined,
|
||||||
|
agentName: selectedAgentObj ? `${selectedAgentObj.emoji} ${selectedAgentObj.name}` : undefined,
|
||||||
|
linkedTaskIds: selectedTasks.value,
|
||||||
|
linkedTaskTitles: linkedTitles,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Optional: Telegram-Benachrichtigung
|
||||||
|
try {
|
||||||
|
await api.post('/messaging/telegram', { text: messageText.value.trim() })
|
||||||
|
} catch {
|
||||||
|
// Telegram ist optional — kein Fehler wenn nicht konfiguriert
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. OpenClaw Wake — Agent sofort wecken (via Backend-Proxy)
|
||||||
|
try {
|
||||||
|
await api.post('/agent-tasks/wake', {})
|
||||||
|
} catch {
|
||||||
|
// Wake ist optional
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({ severity: 'success', summary: 'Auftrag erstellt', detail: 'Auftrag gespeichert und Agent benachrichtigt', life: 3000 })
|
||||||
|
messageText.value = ''
|
||||||
|
selectedTasks.value = []
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Senden fehlgeschlagen'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProjectChange() {
|
||||||
|
selectedTasks.value = []
|
||||||
|
if (selectedProject.value) {
|
||||||
|
loadProjectTasks(selectedProject.value)
|
||||||
|
} else {
|
||||||
|
loadTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebSocket Live-Updates ---
|
||||||
|
function reloadTasks() {
|
||||||
|
if (selectedProject.value) {
|
||||||
|
loadProjectTasks(selectedProject.value)
|
||||||
|
} else {
|
||||||
|
loadTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskStatusChanged(data: unknown) {
|
||||||
|
const { taskId, newStatus } = data as { taskId: string; newStatus: string }
|
||||||
|
if (newStatus === 'in_progress') {
|
||||||
|
// Task wurde auf in_progress gesetzt — neu laden damit er erscheint
|
||||||
|
reloadTasks()
|
||||||
|
} else {
|
||||||
|
// Task ist nicht mehr in_progress — entfernen
|
||||||
|
tasks.value = tasks.value.filter(t => t._id !== taskId)
|
||||||
|
selectedTasks.value = selectedTasks.value.filter(id => id !== taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskUpdated(data: unknown) {
|
||||||
|
const { taskId, changes } = data as { taskId: string; changes: Record<string, unknown> }
|
||||||
|
const task = tasks.value.find(t => t._id === taskId)
|
||||||
|
if (task) {
|
||||||
|
// Status-Änderung über update-Route
|
||||||
|
if (changes.status && String(changes.status) !== 'in_progress') {
|
||||||
|
tasks.value = tasks.value.filter(t => t._id !== taskId)
|
||||||
|
selectedTasks.value = selectedTasks.value.filter(id => id !== taskId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Andere Felder aktualisieren
|
||||||
|
if (changes.title) task.title = String(changes.title)
|
||||||
|
if (changes.priority) task.priority = String(changes.priority)
|
||||||
|
if (changes.status) task.status = String(changes.status)
|
||||||
|
} else if (changes.status === 'in_progress') {
|
||||||
|
reloadTasks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskCreated() {
|
||||||
|
reloadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskDeleted(data: unknown) {
|
||||||
|
const { taskId } = data as { taskId: string }
|
||||||
|
tasks.value = tasks.value.filter(t => t._id !== taskId)
|
||||||
|
selectedTasks.value = selectedTasks.value.filter(id => id !== taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProjects()
|
||||||
|
loadAgents()
|
||||||
|
loadQuickTexts()
|
||||||
|
reloadTasks()
|
||||||
|
|
||||||
|
// WebSocket-Events registrieren
|
||||||
|
wsStore.on('task:status_changed', onTaskStatusChanged)
|
||||||
|
wsStore.on('task:updated', onTaskUpdated)
|
||||||
|
wsStore.on('task:created', onTaskCreated)
|
||||||
|
wsStore.on('task:deleted', onTaskDeleted)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
wsStore.off('task:status_changed', onTaskStatusChanged)
|
||||||
|
wsStore.off('task:updated', onTaskUpdated)
|
||||||
|
wsStore.off('task:created', onTaskCreated)
|
||||||
|
wsStore.off('task:deleted', onTaskDeleted)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="agent-task-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="pi pi-send" style="margin-right: 0.5rem;"></i>Aufgabe stellen</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kontext-Zeile: Projekt + Agent -->
|
||||||
|
<div class="context-bar">
|
||||||
|
<div class="context-item">
|
||||||
|
<label>Projekt</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedProject"
|
||||||
|
:options="projectOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="Alle Projekte"
|
||||||
|
showClear
|
||||||
|
class="dropdown-dark"
|
||||||
|
@change="onProjectChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="context-item">
|
||||||
|
<label>Agent</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedAgent"
|
||||||
|
:options="agentOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="Agent wählen..."
|
||||||
|
showClear
|
||||||
|
class="dropdown-dark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3-Spalten Layout -->
|
||||||
|
<div class="three-col">
|
||||||
|
<!-- Links: Tasks -->
|
||||||
|
<div class="col-tasks">
|
||||||
|
<div class="col-header">
|
||||||
|
<h3><i class="pi pi-list-check"></i> Tasks ({{ filteredTasks.length }})</h3>
|
||||||
|
</div>
|
||||||
|
<div class="task-list-scroll">
|
||||||
|
<div v-if="filteredTasks.length > 0" class="task-list">
|
||||||
|
<div
|
||||||
|
v-for="task in filteredTasks"
|
||||||
|
:key="task._id"
|
||||||
|
class="task-list-item"
|
||||||
|
:class="{ selected: selectedTasks.includes(task._id) }"
|
||||||
|
@click="toggleTask(task._id)"
|
||||||
|
>
|
||||||
|
<i :class="selectedTasks.includes(task._id) ? 'pi pi-check-square' : 'pi pi-stop'"
|
||||||
|
:style="{ color: selectedTasks.includes(task._id) ? '#818cf8' : 'rgba(255,255,255,0.25)', fontSize: '0.95rem' }"></i>
|
||||||
|
<span class="task-prio" :class="'prio-' + String(task.priority || 'medium')">{{ String(task.priority || 'medium').substring(0, 3).toUpperCase() }}</span>
|
||||||
|
<span class="task-status-badge">{{ formatStatus(task.status) }}</span>
|
||||||
|
<span class="task-title clickable" @click.stop="openTaskDialog(task._id)">{{ task.title }}</span>
|
||||||
|
<button class="icon-btn-sm" @click.stop="insertTaskId(task._id)" title="In Nachricht einfügen">
|
||||||
|
<i class="pi pi-arrow-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-hint">Keine offenen Tasks.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mitte: Nachricht -->
|
||||||
|
<div class="col-message">
|
||||||
|
<div class="col-header">
|
||||||
|
<h3><i class="pi pi-comment"></i> Nachricht</h3>
|
||||||
|
</div>
|
||||||
|
<div class="message-area">
|
||||||
|
<textarea
|
||||||
|
v-model="messageText"
|
||||||
|
placeholder="Nachricht eingeben oder einsprechen..."
|
||||||
|
class="message-input"
|
||||||
|
></textarea>
|
||||||
|
<div class="message-toolbar">
|
||||||
|
<button
|
||||||
|
class="toolbar-btn"
|
||||||
|
:class="{ active: recording, pulse: recording }"
|
||||||
|
@click="toggleRecording"
|
||||||
|
:disabled="transcribing"
|
||||||
|
>
|
||||||
|
<i :class="recording ? 'pi pi-stop-circle' : 'pi pi-microphone'"></i>
|
||||||
|
{{ recording ? 'Stop' : transcribing ? 'Transkribiert...' : 'Aufnehmen' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="toolbar-btn send"
|
||||||
|
@click="sendMessage"
|
||||||
|
:disabled="sending || !messageText.trim()"
|
||||||
|
>
|
||||||
|
<i :class="sending ? 'pi pi-spin pi-spinner' : 'pi pi-send'"></i>
|
||||||
|
{{ sending ? 'Sende...' : 'Senden' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rechts: Quick-Texte -->
|
||||||
|
<div class="col-quicktexts">
|
||||||
|
<div class="col-header">
|
||||||
|
<h3><i class="pi pi-bolt"></i> Quick-Texte</h3>
|
||||||
|
<button class="icon-btn" @click="openQuickTextDialog()" title="Neuer Quick-Text">
|
||||||
|
<i class="pi pi-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="quicktext-scroll">
|
||||||
|
<div v-if="quickTexts.length > 0" class="qt-list">
|
||||||
|
<div v-for="qt in quickTexts" :key="qt._id" class="quick-text-card">
|
||||||
|
<div class="qt-content" @click="appendQuickText(qt.text)">
|
||||||
|
<div class="qt-title">{{ qt.title }}</div>
|
||||||
|
<div class="qt-preview">{{ qt.text.substring(0, 60) }}{{ qt.text.length > 60 ? '...' : '' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="qt-actions">
|
||||||
|
<button class="icon-btn-sm" @click.stop="openQuickTextDialog(qt)" title="Bearbeiten">
|
||||||
|
<i class="pi pi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn-sm danger" @click.stop="deleteQuickText(qt._id)" title="Löschen">
|
||||||
|
<i class="pi pi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-hint">Keine Quick-Texte. Klicke + zum Erstellen.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task-Detail Dialog -->
|
||||||
|
<TaskEditDialog
|
||||||
|
:taskId="taskDialogId"
|
||||||
|
v-model:visible="taskDialogVisible"
|
||||||
|
@saved="onTaskDialogSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Quick-Text Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="quickTextDialog"
|
||||||
|
:header="editingQuickText ? 'Quick-Text bearbeiten' : 'Neuer Quick-Text'"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '450px' }"
|
||||||
|
>
|
||||||
|
<div class="dialog-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Titel</label>
|
||||||
|
<input v-model="qtTitle" class="form-input" placeholder="z.B. Bug melden" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Text</label>
|
||||||
|
<textarea v-model="qtText" class="form-input" rows="4" placeholder="Der Text der eingefügt wird..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<button class="toolbar-btn" @click="quickTextDialog = false">Abbrechen</button>
|
||||||
|
<button class="toolbar-btn send" @click="saveQuickText" :disabled="!qtTitle || !qtText">Speichern</button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agent-task-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 2rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kontext-Zeile */
|
||||||
|
.context-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-item label {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-dark {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3-Spalten Layout */
|
||||||
|
.three-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.2fr 0.8fr;
|
||||||
|
gap: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-tasks, .col-message, .col-quicktexts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-header h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Liste */
|
||||||
|
.task-list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-item.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-prio {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prio-urgent { background: rgba(239, 68, 68, 0.2); color: #fca5a5; }
|
||||||
|
.prio-high { background: rgba(249, 115, 22, 0.2); color: #fdba74; }
|
||||||
|
.prio-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; }
|
||||||
|
.prio-low { background: rgba(34, 197, 94, 0.2); color: #86efac; }
|
||||||
|
|
||||||
|
.task-status-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #a5b4fc;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
flex: 1;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title.clickable:hover {
|
||||||
|
color: #818cf8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nachricht */
|
||||||
|
.col-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
|
||||||
|
.toolbar-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.toolbar-btn.active { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.4); color: #fca5a5; }
|
||||||
|
.toolbar-btn.send { background: rgba(99, 102, 241, 0.2); border-color: rgba(99, 102, 241, 0.3); color: #a5b4fc; }
|
||||||
|
.toolbar-btn.send:hover:not(:disabled) { background: rgba(99, 102, 241, 0.35); color: #fff; }
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||||
|
}
|
||||||
|
.pulse { animation: pulse-glow 1.5s infinite; }
|
||||||
|
|
||||||
|
/* Quick-Texte */
|
||||||
|
.quicktext-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qt-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-text-card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-text-card:hover {
|
||||||
|
border-color: rgba(99, 102, 241, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qt-content {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qt-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qt-preview {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qt-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.15rem 0.5rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #a5b4fc;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover { background: rgba(99, 102, 241, 0.3); color: #fff; }
|
||||||
|
|
||||||
|
.icon-btn-sm {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-sm:hover { color: #fff; }
|
||||||
|
.icon-btn-sm.danger:hover { color: #ef4444; }
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog Dark Theme - moved to global style block below */
|
||||||
|
|
||||||
|
/* Dialog Form */
|
||||||
|
.dialog-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.form-group label { color: rgba(255, 255, 255, 0.7); font-size: 0.85rem; font-weight: 500; }
|
||||||
|
.form-input {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-input:focus { border-color: #6366f1; }
|
||||||
|
.form-input::placeholder { color: rgba(255, 255, 255, 0.3); }
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.task-list-scroll::-webkit-scrollbar,
|
||||||
|
.quicktext-scroll::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.task-list-scroll::-webkit-scrollbar-thumb,
|
||||||
|
.quicktext-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.task-list-scroll::-webkit-scrollbar-thumb:hover,
|
||||||
|
.quicktext-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive — unter 900px: Single-Column */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.agent-task-page {
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.three-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-scroll {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quicktext-scroll {
|
||||||
|
max-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-message {
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-dark {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Dialog Dark Theme now in global style.css -->
|
||||||
677
src/views/AgentTasksOverview.vue
Normal file
677
src/views/AgentTasksOverview.vue
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import Toast from 'primevue/toast'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
interface AgentTask {
|
||||||
|
_id: string
|
||||||
|
number?: number
|
||||||
|
message: string
|
||||||
|
projectId?: string
|
||||||
|
projectName?: string
|
||||||
|
agentId?: string
|
||||||
|
agentName?: string
|
||||||
|
linkedTaskIds: string[]
|
||||||
|
linkedTaskTitles: string[]
|
||||||
|
status: 'pending' | 'in_progress' | 'done' | 'rejected'
|
||||||
|
createdBy: string
|
||||||
|
createdByName: string
|
||||||
|
result?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const tasks = ref<AgentTask[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const statusFilter = ref<string>('all')
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const editDialogVisible = ref(false)
|
||||||
|
const editTask = ref<AgentTask | null>(null)
|
||||||
|
const editMessage = ref('')
|
||||||
|
const editLoading = ref(false)
|
||||||
|
|
||||||
|
// Detail state
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const detailTask = ref<AgentTask | null>(null)
|
||||||
|
|
||||||
|
const filteredTasks = computed(() => {
|
||||||
|
if (statusFilter.value === 'all') return tasks.value
|
||||||
|
return tasks.value.filter(t => t.status === statusFilter.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusCounts = computed(() => {
|
||||||
|
const counts: Record<string, number> = { all: tasks.value.length }
|
||||||
|
for (const t of tasks.value) {
|
||||||
|
counts[t.status] = (counts[t.status] || 0) + 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ tasks: AgentTask[] }>('/agent-tasks')
|
||||||
|
tasks.value = res.tasks || []
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Agent-Tasks konnten nicht geladen werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(task: AgentTask) {
|
||||||
|
detailTask.value = task
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(task: AgentTask) {
|
||||||
|
editTask.value = task
|
||||||
|
editMessage.value = task.message
|
||||||
|
editDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editTask.value) return
|
||||||
|
editLoading.value = true
|
||||||
|
try {
|
||||||
|
await api.put(`/agent-tasks/${editTask.value._id}`, { message: editMessage.value })
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Auftrag aktualisiert', life: 3000 })
|
||||||
|
editDialogVisible.value = false
|
||||||
|
await loadTasks()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Speichern fehlgeschlagen', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
editLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicateTask(task: AgentTask) {
|
||||||
|
try {
|
||||||
|
await api.post('/agent-tasks', {
|
||||||
|
message: task.message,
|
||||||
|
projectId: task.projectId,
|
||||||
|
projectName: task.projectName,
|
||||||
|
agentId: task.agentId,
|
||||||
|
agentName: task.agentName,
|
||||||
|
linkedTaskIds: task.linkedTaskIds,
|
||||||
|
linkedTaskTitles: task.linkedTaskTitles,
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Kopiert', detail: 'Neuer pending Auftrag erstellt — bearbeite ihn jetzt', life: 4000 })
|
||||||
|
await loadTasks()
|
||||||
|
// Open the new pending task for editing
|
||||||
|
const newTask = tasks.value.find(t => t.status === 'pending' && t.message === task.message)
|
||||||
|
if (newTask) openEdit(newTask)
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Kopieren fehlgeschlagen', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendTask(task: AgentTask) {
|
||||||
|
try {
|
||||||
|
await api.post('/agent-tasks/wake', {})
|
||||||
|
toast.add({ severity: 'success', summary: 'Gesendet', detail: 'Agent wurde geweckt', life: 3000 })
|
||||||
|
} catch {
|
||||||
|
// Wake is optional
|
||||||
|
toast.add({ severity: 'info', summary: 'Gespeichert', detail: 'Auftrag erstellt (Wake fehlgeschlagen)', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending: 'Ausstehend',
|
||||||
|
in_progress: 'In Bearbeitung',
|
||||||
|
done: 'Erledigt',
|
||||||
|
rejected: 'Abgelehnt',
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pending: 'status-pending',
|
||||||
|
in_progress: 'status-progress',
|
||||||
|
done: 'status-done',
|
||||||
|
rejected: 'status-rejected',
|
||||||
|
}
|
||||||
|
return map[status] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTasks)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="overview-view">
|
||||||
|
<Toast />
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h2><i class="pi pi-list-check"></i> Agent-Aufträge</h2>
|
||||||
|
<button class="dialog-btn" @click="loadTasks" :disabled="loading">
|
||||||
|
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<button
|
||||||
|
v-for="s in ['all', 'pending', 'in_progress', 'done', 'rejected']"
|
||||||
|
:key="s"
|
||||||
|
class="filter-btn"
|
||||||
|
:class="{ active: statusFilter === s }"
|
||||||
|
@click="statusFilter = s"
|
||||||
|
>
|
||||||
|
{{ s === 'all' ? 'Alle' : statusLabel(s) }}
|
||||||
|
<span class="filter-count">{{ statusCounts[s] || 0 }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task List -->
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Lade Aufträge...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredTasks.length === 0" class="empty-state">
|
||||||
|
Keine Aufträge gefunden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="task-list">
|
||||||
|
<div v-for="task in filteredTasks" :key="task._id" class="task-card">
|
||||||
|
<div class="task-header">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span v-if="task.number" class="task-number">#{{ task.number }}</span>
|
||||||
|
<span class="status-badge" :class="statusClass(task.status)">
|
||||||
|
{{ statusLabel(task.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="task-date">{{ formatDate(task.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-message" @click="openDetail(task)">
|
||||||
|
{{ task.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="task.projectName" class="task-meta">
|
||||||
|
<i class="pi pi-folder"></i> {{ task.projectName }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="task.linkedTaskTitles?.length" class="task-meta">
|
||||||
|
<i class="pi pi-link"></i> {{ task.linkedTaskTitles.join(', ') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="task.result" class="task-result">
|
||||||
|
<i class="pi pi-check-circle"></i> {{ task.result }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-actions">
|
||||||
|
<button
|
||||||
|
v-if="task.status === 'pending'"
|
||||||
|
class="action-btn edit"
|
||||||
|
@click="openEdit(task)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil"></i> Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="task.status === 'pending'"
|
||||||
|
class="action-btn send"
|
||||||
|
@click="resendTask(task)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-send"></i> Erneut senden
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="task.status === 'done' || task.status === 'rejected'"
|
||||||
|
class="action-btn copy"
|
||||||
|
@click="duplicateTask(task)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-copy"></i> Kopie erstellen
|
||||||
|
</button>
|
||||||
|
<button class="action-btn detail" @click="openDetail(task)">
|
||||||
|
<i class="pi pi-eye"></i> Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="editDialogVisible"
|
||||||
|
header="Auftrag bearbeiten"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '36rem' }"
|
||||||
|
>
|
||||||
|
<div class="edit-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nachricht</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editMessage"
|
||||||
|
class="form-input"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Auftrag beschreiben..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-if="editTask?.linkedTaskTitles?.length" class="form-group">
|
||||||
|
<label>Verknüpfte Tasks</label>
|
||||||
|
<div class="linked-tags">
|
||||||
|
<span v-for="title in editTask.linkedTaskTitles" :key="title" class="linked-tag">
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<button class="dialog-btn" @click="editDialogVisible = false">Abbrechen</button>
|
||||||
|
<button
|
||||||
|
class="dialog-btn primary"
|
||||||
|
:disabled="!editMessage.trim() || editLoading"
|
||||||
|
@click="saveEdit"
|
||||||
|
>
|
||||||
|
<i v-if="editLoading" class="pi pi-spin pi-spinner"></i>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Detail Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="detailDialogVisible"
|
||||||
|
header="Auftragsdetails"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '40rem' }"
|
||||||
|
>
|
||||||
|
<div v-if="detailTask" class="detail-content">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Status</span>
|
||||||
|
<span class="status-badge" :class="statusClass(detailTask.status)">
|
||||||
|
{{ statusLabel(detailTask.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Erstellt</span>
|
||||||
|
<span>{{ formatDate(detailTask.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Von</span>
|
||||||
|
<span>{{ detailTask.createdByName }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="detailTask.projectName" class="detail-row">
|
||||||
|
<span class="detail-label">Projekt</span>
|
||||||
|
<span>{{ detailTask.projectName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Nachricht</span>
|
||||||
|
<div class="detail-message">{{ detailTask.message }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detailTask.linkedTaskTitles?.length" class="detail-row">
|
||||||
|
<span class="detail-label">Verknüpft</span>
|
||||||
|
<div class="linked-tags">
|
||||||
|
<span v-for="title in detailTask.linkedTaskTitles" :key="title" class="linked-tag">
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detailTask.result" class="detail-row">
|
||||||
|
<span class="detail-label">Ergebnis</span>
|
||||||
|
<div class="detail-result">{{ detailTask.result }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<button class="dialog-btn" @click="detailDialogVisible = false">Schließen</button>
|
||||||
|
<button
|
||||||
|
v-if="detailTask?.status === 'pending'"
|
||||||
|
class="dialog-btn primary"
|
||||||
|
@click="detailDialogVisible = false; openEdit(detailTask!)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil"></i> Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="detailTask?.status === 'done' || detailTask?.status === 'rejected'"
|
||||||
|
class="dialog-btn primary"
|
||||||
|
@click="detailDialogVisible = false; duplicateTask(detailTask!)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-copy"></i> Kopie erstellen
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overview-view {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Bar */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border-color: rgba(99, 102, 241, 0.3);
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-count {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
min-width: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active .filter-count {
|
||||||
|
background: rgba(99, 102, 241, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task List */
|
||||||
|
.task-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-number {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-date {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: #facc15;
|
||||||
|
border: 1px solid rgba(234, 179, 8, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-progress {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: #a5b4fc;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-done {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #4ade80;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-rejected {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-message {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-message:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta i {
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-result {
|
||||||
|
background: rgba(34, 197, 94, 0.08);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: rgba(74, 222, 128, 0.85);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-result i {
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||||
|
.action-btn.edit:hover { color: #a5b4fc; border-color: rgba(99, 102, 241, 0.3); }
|
||||||
|
.action-btn.send:hover { color: #4ade80; border-color: rgba(34, 197, 94, 0.3); }
|
||||||
|
.action-btn.copy:hover { color: #facc15; border-color: rgba(234, 179, 8, 0.3); }
|
||||||
|
.action-btn.detail:hover { color: #e0e0e0; }
|
||||||
|
|
||||||
|
/* Loading & Empty */
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog styles */
|
||||||
|
.dialog-btn {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
|
||||||
|
|
||||||
|
.dialog-btn.primary {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border-color: rgba(99, 102, 241, 0.3);
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.primary:hover:not(:disabled) {
|
||||||
|
background: rgba(99, 102, 241, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Edit Form */
|
||||||
|
.edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus { border-color: #6366f1; }
|
||||||
|
.form-input::placeholder { color: rgba(255, 255, 255, 0.3); }
|
||||||
|
|
||||||
|
.linked-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-tag {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail Dialog */
|
||||||
|
.detail-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-message {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-result {
|
||||||
|
background: rgba(34, 197, 94, 0.08);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: rgba(74, 222, 128, 0.85);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
404
src/views/AgentsView.vue
Normal file
404
src/views/AgentsView.vue
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import AgentFiles from '../components/AgentFiles.vue'
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
role: string
|
||||||
|
status: 'online' | 'idle' | 'offline' | 'busy'
|
||||||
|
supervisor?: string
|
||||||
|
discordId?: string
|
||||||
|
containerId?: string
|
||||||
|
containerIp?: string
|
||||||
|
lastSeen?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const agents = ref<Agent[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
const newAgent = ref({ name: '', emoji: '🤖', role: '', status: 'offline' as const })
|
||||||
|
const expandedAgent = ref<string | null>(null)
|
||||||
|
|
||||||
|
function toggleFiles(agentId: string) {
|
||||||
|
expandedAgent.value = expandedAgent.value === agentId ? null : agentId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ agents: Agent[] }>('/agents')
|
||||||
|
agents.value = data.agents
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Agents konnten nicht geladen werden', life: 5000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAgent() {
|
||||||
|
if (!newAgent.value.name || !newAgent.value.role) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Name und Rolle sind erforderlich', life: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/agents', newAgent.value)
|
||||||
|
toast.add({ severity: 'success', summary: 'Erfolg', detail: 'Agent erstellt', life: 3000 })
|
||||||
|
showAddModal.value = false
|
||||||
|
newAgent.value = { name: '', emoji: '🤖', role: '', status: 'offline' }
|
||||||
|
await loadAgents()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAgent(id: string, name: string) {
|
||||||
|
if (!confirm(`Agent "${name}" wirklich löschen?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/agents/${id}`)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gelöscht', detail: `Agent ${name} wurde entfernt`, life: 3000 })
|
||||||
|
await loadAgents()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAgents)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h1>Agents</h1>
|
||||||
|
<button v-if="authStore.user?.role === 'admin'" @click="showAddModal = true" class="add-btn">
|
||||||
|
<i class="pi pi-plus"></i> Agent hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="agents.length === 0" class="empty-state">
|
||||||
|
<i class="pi pi-users" style="font-size: 3rem; color: rgba(255,255,255,0.3);"></i>
|
||||||
|
<p>Noch keine Agents vorhanden</p>
|
||||||
|
<button v-if="authStore.user?.role === 'admin'" @click="showAddModal = true" class="add-btn">
|
||||||
|
Ersten Agent erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="agents-grid">
|
||||||
|
<div v-for="agent in agents" :key="agent._id" class="agent-wrapper">
|
||||||
|
<div class="agent-card" @click="toggleFiles(agent._id)">
|
||||||
|
<div class="agent-avatar">{{ agent.emoji }}</div>
|
||||||
|
<div class="agent-info">
|
||||||
|
<div class="agent-name">{{ agent.name }}</div>
|
||||||
|
<div class="agent-role">{{ agent.role }}</div>
|
||||||
|
<div v-if="agent.containerIp" class="agent-ip">{{ agent.containerIp }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="agent-actions">
|
||||||
|
<button class="files-btn" :class="{ active: expandedAgent === agent._id }" title="Workspace Dateien" @click.stop="toggleFiles(agent._id)">
|
||||||
|
<i class="pi pi-file"></i>
|
||||||
|
</button>
|
||||||
|
<div class="agent-status" :class="agent.status">
|
||||||
|
{{ agent.status }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="authStore.user?.role === 'admin'"
|
||||||
|
@click.stop="deleteAgent(agent._id, agent.name)"
|
||||||
|
class="delete-btn"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<i class="pi pi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedAgent === agent._id" class="agent-files-panel">
|
||||||
|
<AgentFiles :agentId="agent._id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Agent Modal -->
|
||||||
|
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Neuer Agent</h2>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input v-model="newAgent.name" type="text" placeholder="z.B. Kernel Panic" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Emoji</label>
|
||||||
|
<input v-model="newAgent.emoji" type="text" placeholder="🤖" style="width: 80px;" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Rolle</label>
|
||||||
|
<input v-model="newAgent.role" type="text" placeholder="z.B. Backend Dev" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Status</label>
|
||||||
|
<select v-model="newAgent.status">
|
||||||
|
<option value="offline">Offline</option>
|
||||||
|
<option value="online">Online</option>
|
||||||
|
<option value="idle">Idle</option>
|
||||||
|
<option value="busy">Busy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="showAddModal = false" class="cancel-btn">Abbrechen</button>
|
||||||
|
<button @click="addAgent" class="submit-btn">Erstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agents-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-wrapper {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-role {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-ip {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status.online {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status.idle {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status.offline {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-status.busy {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #f87171;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
border-color: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-btn.active {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
color: #818cf8;
|
||||||
|
border-color: rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-files-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input, .form-field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input:focus, .form-field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
453
src/views/ContainersView.vue
Normal file
453
src/views/ContainersView.vue
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
interface Container {
|
||||||
|
ID: string
|
||||||
|
Names: string
|
||||||
|
Image: string
|
||||||
|
State: string
|
||||||
|
Status: string
|
||||||
|
Ports: string
|
||||||
|
Networks: string
|
||||||
|
CreatedAt: string
|
||||||
|
RunningFor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const containers = ref<Container[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const actionLoading = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Log viewer
|
||||||
|
const showLogDialog = ref(false)
|
||||||
|
const logContainer = ref<Container | null>(null)
|
||||||
|
const logContent = ref('')
|
||||||
|
const logLoading = ref(false)
|
||||||
|
const logTail = ref(100)
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const sortedContainers = computed(() => {
|
||||||
|
return [...containers.value].sort((a, b) => {
|
||||||
|
// Running first
|
||||||
|
if (a.State === 'running' && b.State !== 'running') return -1
|
||||||
|
if (b.State === 'running' && a.State !== 'running') return 1
|
||||||
|
return a.Names.localeCompare(b.Names)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = computed(() => ({
|
||||||
|
total: containers.value.length,
|
||||||
|
running: containers.value.filter(c => c.State === 'running').length,
|
||||||
|
stopped: containers.value.filter(c => c.State !== 'running').length
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function loadContainers() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ containers: Container[] }>('/docker/containers')
|
||||||
|
containers.value = res.containers || []
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Container konnten nicht geladen werden', life: 5000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function containerAction(id: string, action: 'start' | 'stop' | 'restart') {
|
||||||
|
actionLoading.value = id + action
|
||||||
|
try {
|
||||||
|
await api.post(`/docker/containers/${id}/${action}`)
|
||||||
|
toast.add({ severity: 'success', summary: 'Erfolg', detail: `Container ${action}`, life: 3000 })
|
||||||
|
await loadContainers()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewLogs(container: Container) {
|
||||||
|
logContainer.value = container
|
||||||
|
showLogDialog.value = true
|
||||||
|
await fetchLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogs() {
|
||||||
|
if (!logContainer.value) return
|
||||||
|
logLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ logs: string }>(`/docker/containers/${logContainer.value.ID}/logs?tail=${logTail.value}`)
|
||||||
|
logContent.value = res.logs || 'Keine Logs vorhanden'
|
||||||
|
} catch {
|
||||||
|
logContent.value = 'Fehler beim Laden der Logs'
|
||||||
|
} finally {
|
||||||
|
logLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateClass(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'running': return 'state-running'
|
||||||
|
case 'exited': return 'state-exited'
|
||||||
|
case 'created': return 'state-created'
|
||||||
|
case 'restarting': return 'state-restarting'
|
||||||
|
default: return 'state-unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShortName(names: string): string {
|
||||||
|
return names.replace(/^\//, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShortImage(image: string): string {
|
||||||
|
// Shorten registry paths
|
||||||
|
return image.replace(/^registry\.agentenbude\.de\/agent-management\//, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadContainers()
|
||||||
|
refreshTimer = setInterval(loadContainers, 15000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="pi pi-box"></i> Container</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="container-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-number">{{ stats.total }}</span>
|
||||||
|
<span class="stat-label">Gesamt</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card running">
|
||||||
|
<span class="stat-number">{{ stats.running }}</span>
|
||||||
|
<span class="stat-label">Laufend</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stopped">
|
||||||
|
<span class="stat-number">{{ stats.stopped }}</span>
|
||||||
|
<span class="stat-label">Gestoppt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="authStore.user?.role !== 'admin'" class="empty-state">
|
||||||
|
<i class="pi pi-lock"></i>
|
||||||
|
<p>Nur Admins können Container verwalten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="container-list">
|
||||||
|
<div
|
||||||
|
v-for="c in sortedContainers"
|
||||||
|
:key="c.ID"
|
||||||
|
class="container-card"
|
||||||
|
:class="getStateClass(c.State)"
|
||||||
|
>
|
||||||
|
<div class="container-header">
|
||||||
|
<div class="container-info">
|
||||||
|
<span class="container-state-dot" :class="getStateClass(c.State)">●</span>
|
||||||
|
<h3>{{ getShortName(c.Names) }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="container-actions">
|
||||||
|
<button
|
||||||
|
v-if="c.State !== 'running'"
|
||||||
|
class="action-btn start"
|
||||||
|
:disabled="actionLoading === c.ID + 'start'"
|
||||||
|
@click="containerAction(c.ID, 'start')"
|
||||||
|
title="Starten"
|
||||||
|
>
|
||||||
|
<i :class="actionLoading === c.ID + 'start' ? 'pi pi-spin pi-spinner' : 'pi pi-play'"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="c.State === 'running'"
|
||||||
|
class="action-btn stop"
|
||||||
|
:disabled="actionLoading === c.ID + 'stop'"
|
||||||
|
@click="containerAction(c.ID, 'stop')"
|
||||||
|
title="Stoppen"
|
||||||
|
>
|
||||||
|
<i :class="actionLoading === c.ID + 'stop' ? 'pi pi-spin pi-spinner' : 'pi pi-stop-circle'"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn restart"
|
||||||
|
:disabled="actionLoading === c.ID + 'restart'"
|
||||||
|
@click="containerAction(c.ID, 'restart')"
|
||||||
|
title="Neustarten"
|
||||||
|
>
|
||||||
|
<i :class="actionLoading === c.ID + 'restart' ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn logs" @click="viewLogs(c)" title="Logs">
|
||||||
|
<i class="pi pi-file"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container-details">
|
||||||
|
<div class="detail">
|
||||||
|
<span class="detail-label">Image</span>
|
||||||
|
<span class="detail-value mono">{{ getShortImage(c.Image) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail">
|
||||||
|
<span class="detail-label">Status</span>
|
||||||
|
<span class="detail-value">{{ c.Status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail" v-if="c.Ports">
|
||||||
|
<span class="detail-label">Ports</span>
|
||||||
|
<span class="detail-value mono">{{ c.Ports }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail" v-if="c.Networks">
|
||||||
|
<span class="detail-label">Netzwerk</span>
|
||||||
|
<span class="detail-value mono">{{ c.Networks }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showLogDialog"
|
||||||
|
:header="'Logs: ' + (logContainer ? getShortName(logContainer.Names) : '')"
|
||||||
|
:style="{ width: '90vw', maxWidth: '900px' }"
|
||||||
|
:contentStyle="{ padding: 0 }"
|
||||||
|
modal
|
||||||
|
appendTo="self"
|
||||||
|
>
|
||||||
|
<div class="log-controls">
|
||||||
|
<label>Zeilen:</label>
|
||||||
|
<select v-model="logTail" @change="fetchLogs" class="log-tail-select">
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
<option :value="500">500</option>
|
||||||
|
<option :value="1000">1000</option>
|
||||||
|
</select>
|
||||||
|
<button class="action-btn refresh" @click="fetchLogs" :disabled="logLoading">
|
||||||
|
<i :class="logLoading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i> Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="log-content" v-if="!logLoading">{{ logContent }}</pre>
|
||||||
|
<div v-else class="log-loading">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Lade Logs...
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.running { border-color: rgba(34, 197, 94, 0.3); }
|
||||||
|
.stat-card.stopped { border-color: rgba(239, 68, 68, 0.3); }
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.running .stat-number { color: #4ade80; }
|
||||||
|
.stat-card.stopped .stat-number { color: #f87171; }
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i { font-size: 3rem; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.container-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 3px solid #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-card.state-running { border-left-color: #22c55e; }
|
||||||
|
.container-card.state-exited { border-left-color: #ef4444; }
|
||||||
|
.container-card.state-created { border-left-color: #f59e0b; }
|
||||||
|
.container-card.state-restarting { border-left-color: #3b82f6; }
|
||||||
|
|
||||||
|
.container-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-info h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-state-dot {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-state-dot.state-running { color: #22c55e; }
|
||||||
|
.container-state-dot.state-exited { color: #ef4444; }
|
||||||
|
.container-state-dot.state-created { color: #f59e0b; }
|
||||||
|
.container-state-dot.state-restarting { color: #3b82f6; }
|
||||||
|
|
||||||
|
.container-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||||
|
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.action-btn.start:hover { border-color: #22c55e; color: #22c55e; }
|
||||||
|
.action-btn.stop:hover { border-color: #ef4444; color: #ef4444; }
|
||||||
|
.action-btn.restart:hover { border-color: #3b82f6; color: #3b82f6; }
|
||||||
|
.action-btn.logs:hover { border-color: #a78bfa; color: #a78bfa; }
|
||||||
|
|
||||||
|
.container-details {
|
||||||
|
padding: 0 1rem 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value.mono {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log Dialog */
|
||||||
|
.log-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-controls label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-tail-select {
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-tail-select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-loading {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-dialog) { background: #161b22; border: 1px solid rgba(255, 255, 255, 0.1); }
|
||||||
|
:deep(.p-dialog-header) { background: #161b22; color: #e6edf3; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
|
||||||
|
:deep(.p-dialog-content) { background: #161b22; color: #e6edf3; padding: 0 !important; }
|
||||||
|
</style>
|
||||||
394
src/views/CronJobsView.vue
Normal file
394
src/views/CronJobsView.vue
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import Toast from 'primevue/toast'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
interface CronJob {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
intervalMinutes: number
|
||||||
|
enabled: boolean
|
||||||
|
lastRun: string | null
|
||||||
|
lastResult: string | null
|
||||||
|
nextRun: string | null
|
||||||
|
timerActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const jobs = ref<CronJob[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// Create/Edit state
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingJob = ref<CronJob | null>(null)
|
||||||
|
const formName = ref('')
|
||||||
|
const formDescription = ref('')
|
||||||
|
const formInterval = ref(30)
|
||||||
|
const formEnabled = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function loadJobs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ jobs: CronJob[] }>('/cronjobs')
|
||||||
|
jobs.value = res.jobs || []
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'CronJobs konnten nicht geladen werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingJob.value = null
|
||||||
|
formName.value = ''
|
||||||
|
formDescription.value = 'Prüft pending Agent-Tasks und weckt den Agenten'
|
||||||
|
formInterval.value = 30
|
||||||
|
formEnabled.value = true
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(job: CronJob) {
|
||||||
|
editingJob.value = job
|
||||||
|
formName.value = job.name
|
||||||
|
formDescription.value = job.description
|
||||||
|
formInterval.value = job.intervalMinutes
|
||||||
|
formEnabled.value = job.enabled
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveJob() {
|
||||||
|
if (!formName.value.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingJob.value) {
|
||||||
|
await api.put(`/cronjobs/${editingJob.value._id}`, {
|
||||||
|
name: formName.value.trim(),
|
||||||
|
description: formDescription.value,
|
||||||
|
intervalMinutes: formInterval.value,
|
||||||
|
enabled: formEnabled.value,
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Aktualisiert', detail: 'CronJob gespeichert', life: 3000 })
|
||||||
|
} else {
|
||||||
|
await api.post('/cronjobs', {
|
||||||
|
name: formName.value.trim(),
|
||||||
|
description: formDescription.value,
|
||||||
|
intervalMinutes: formInterval.value,
|
||||||
|
enabled: formEnabled.value,
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'CronJob erstellt', life: 3000 })
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadJobs()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Speichern fehlgeschlagen', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(job: CronJob) {
|
||||||
|
try {
|
||||||
|
await api.put(`/cronjobs/${job._id}`, { enabled: !job.enabled })
|
||||||
|
toast.add({ severity: 'success', summary: job.enabled ? 'Deaktiviert' : 'Aktiviert', detail: job.name, life: 2000 })
|
||||||
|
await loadJobs()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Status konnte nicht geändert werden', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow(job: CronJob) {
|
||||||
|
try {
|
||||||
|
await api.post(`/cronjobs/${job._id}/run`, {})
|
||||||
|
toast.add({ severity: 'success', summary: 'Ausgeführt', detail: `${job.name} manuell gestartet`, life: 3000 })
|
||||||
|
await loadJobs()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Ausführung fehlgeschlagen', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJob(job: CronJob) {
|
||||||
|
if (!confirm(`CronJob "${job.name}" wirklich löschen?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/cronjobs/${job._id}`)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gelöscht', detail: job.name, life: 3000 })
|
||||||
|
await loadJobs()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Löschen fehlgeschlagen', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null) {
|
||||||
|
if (!dateStr) return '–'
|
||||||
|
return new Date(dateStr).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInterval(minutes: number) {
|
||||||
|
if (minutes < 60) return `${minutes} Min.`
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
|
return m > 0 ? `${h} Std. ${m} Min.` : `${h} Std.`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadJobs)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cronjobs-view">
|
||||||
|
<Toast />
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h2><i class="pi pi-clock"></i> CronJobs</h2>
|
||||||
|
<button class="dialog-btn primary" @click="openCreate">
|
||||||
|
<i class="pi pi-plus"></i> Neuer CronJob
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Lade CronJobs...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="jobs.length === 0" class="empty-state">
|
||||||
|
<i class="pi pi-clock" style="font-size: 2rem; opacity: 0.3"></i>
|
||||||
|
<p>Keine CronJobs vorhanden.</p>
|
||||||
|
<button class="dialog-btn primary" @click="openCreate">Jetzt erstellen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="jobs-list">
|
||||||
|
<div v-for="job in jobs" :key="job._id" class="job-card" :class="{ disabled: !job.enabled }">
|
||||||
|
<div class="job-header">
|
||||||
|
<div class="job-title-row">
|
||||||
|
<span class="job-indicator" :class="{ active: job.enabled && job.timerActive }"></span>
|
||||||
|
<h3 class="job-name">{{ job.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="job-actions">
|
||||||
|
<button class="icon-btn" @click="runNow(job)" title="Jetzt ausführen">
|
||||||
|
<i class="pi pi-play"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" @click="toggleEnabled(job)" :title="job.enabled ? 'Deaktivieren' : 'Aktivieren'">
|
||||||
|
<i :class="job.enabled ? 'pi pi-pause' : 'pi pi-play-circle'"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" @click="openEdit(job)" title="Bearbeiten">
|
||||||
|
<i class="pi pi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn danger" @click="deleteJob(job)" title="Löschen">
|
||||||
|
<i class="pi pi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="job.description" class="job-desc">{{ job.description }}</p>
|
||||||
|
|
||||||
|
<div class="job-meta-grid">
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Intervall</span>
|
||||||
|
<span class="meta-value">{{ formatInterval(job.intervalMinutes) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Status</span>
|
||||||
|
<span class="meta-value" :class="job.enabled ? 'text-green' : 'text-red'">
|
||||||
|
{{ job.enabled ? 'Aktiv' : 'Inaktiv' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Letzter Lauf</span>
|
||||||
|
<span class="meta-value">{{ formatDate(job.lastRun) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span class="meta-label">Nächster Lauf</span>
|
||||||
|
<span class="meta-value">{{ formatDate(job.nextRun) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="job.lastResult" class="job-result">
|
||||||
|
<i class="pi pi-info-circle"></i> {{ job.lastResult }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="dialogVisible"
|
||||||
|
:header="editingJob ? 'CronJob bearbeiten' : 'Neuer CronJob'"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '28rem' }"
|
||||||
|
>
|
||||||
|
<div class="edit-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input v-model="formName" class="form-input" placeholder="z.B. Agent-Task Check" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<input v-model="formDescription" class="form-input" placeholder="Was macht dieser Job?" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Intervall (Minuten)</label>
|
||||||
|
<input v-model.number="formInterval" type="number" min="1" class="form-input" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" v-model="formEnabled" />
|
||||||
|
Sofort aktivieren
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<button class="dialog-btn" @click="dialogVisible = false">Abbrechen</button>
|
||||||
|
<button class="dialog-btn primary" :disabled="!formName.trim() || saving" @click="saveJob">
|
||||||
|
<i v-if="saving" class="pi pi-spin pi-spinner"></i>
|
||||||
|
{{ editingJob ? 'Speichern' : 'Erstellen' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cronjobs-view { padding: 1.5rem; }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 { margin: 0; color: #e0e0e0; font-size: 1.4rem; }
|
||||||
|
.page-header h2 i { margin-right: 0.5rem; }
|
||||||
|
|
||||||
|
.loading-state, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p { margin: 1rem 0; }
|
||||||
|
|
||||||
|
.jobs-list { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.job-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-card:hover { border-color: rgba(255, 255, 255, 0.15); }
|
||||||
|
.job-card.disabled { opacity: 0.6; }
|
||||||
|
|
||||||
|
.job-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
|
||||||
|
.job-title-row { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.job-indicator {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-indicator.active {
|
||||||
|
background: #4ade80;
|
||||||
|
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-name { margin: 0; color: #e0e0e0; font-size: 1rem; }
|
||||||
|
|
||||||
|
.job-actions { display: flex; gap: 0.25rem; }
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none; border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
cursor: pointer; padding: 0.35rem;
|
||||||
|
border-radius: 6px; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover { color: #a5b4fc; background: rgba(99, 102, 241, 0.15); }
|
||||||
|
.icon-btn.danger:hover { color: #f87171; background: rgba(248, 113, 113, 0.15); }
|
||||||
|
|
||||||
|
.job-desc { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 0 0 0.75rem 0; }
|
||||||
|
|
||||||
|
.job-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||||
|
.meta-label { color: rgba(255, 255, 255, 0.4); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.meta-value { color: #d0d0d0; font-size: 0.85rem; }
|
||||||
|
.text-green { color: #4ade80; }
|
||||||
|
.text-red { color: #f87171; }
|
||||||
|
|
||||||
|
.job-result {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-result i { margin-right: 0.3rem; }
|
||||||
|
|
||||||
|
/* Dialog styles */
|
||||||
|
.dialog-btn {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
|
||||||
|
|
||||||
|
.dialog-btn.primary {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border-color: rgba(99, 102, 241, 0.3);
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.primary:hover:not(:disabled) { background: rgba(99, 102, 241, 0.35); color: #fff; }
|
||||||
|
.dialog-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.edit-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.form-group label { color: rgba(255, 255, 255, 0.7); font-size: 0.85rem; font-weight: 500; }
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus { border-color: #6366f1; }
|
||||||
|
.form-input::placeholder { color: rgba(255, 255, 255, 0.3); }
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input[type="checkbox"] { accent-color: #6366f1; }
|
||||||
|
</style>
|
||||||
303
src/views/DashboardView.vue
Normal file
303
src/views/DashboardView.vue
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
agents: { total: number; online: number }
|
||||||
|
tasks: { total: number; done: number; inProgress: number; todo: number }
|
||||||
|
projects: number
|
||||||
|
logs: { info: number; warn: number; error: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = ref<Stats>({
|
||||||
|
agents: { total: 0, online: 0 },
|
||||||
|
tasks: { total: 0, done: 0, inProgress: 0, todo: 0 },
|
||||||
|
projects: 0,
|
||||||
|
logs: { info: 0, warn: 0, error: 0 }
|
||||||
|
})
|
||||||
|
const recentLogs = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const [agentsRes, tasksRes, projectsRes, logsRes] = await Promise.all([
|
||||||
|
api.get<{ agents: any[] }>('/agents'),
|
||||||
|
api.get<{ tasks: any[] }>('/tasks'),
|
||||||
|
api.get<{ projects: any[] }>('/tasks/projects/list'),
|
||||||
|
api.get<{ logs: any[] }>('/logs?limit=5&hours=24')
|
||||||
|
])
|
||||||
|
|
||||||
|
const agents = agentsRes.agents
|
||||||
|
const tasks = tasksRes.tasks
|
||||||
|
|
||||||
|
stats.value = {
|
||||||
|
agents: {
|
||||||
|
total: agents.length,
|
||||||
|
online: agents.filter(a => a.status === 'online' || a.status === 'busy').length
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
total: tasks.length,
|
||||||
|
done: tasks.filter(t => t.status === 'done').length,
|
||||||
|
inProgress: tasks.filter(t => t.status === 'in_progress').length,
|
||||||
|
todo: tasks.filter(t => t.status === 'todo' || t.status === 'backlog').length
|
||||||
|
},
|
||||||
|
projects: projectsRes.projects.length,
|
||||||
|
logs: { info: 0, warn: 0, error: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
recentLogs.value = logsRes.logs
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load stats:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: string) {
|
||||||
|
return new Date(timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadStats)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: rgba(99, 102, 241, 0.2); color: #818cf8;">
|
||||||
|
<i class="pi pi-users"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.agents.online }}/{{ stats.agents.total }}</div>
|
||||||
|
<div class="stat-label">Agents Online</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: rgba(34, 197, 94, 0.2); color: #4ade80;">
|
||||||
|
<i class="pi pi-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.tasks.done }}</div>
|
||||||
|
<div class="stat-label">Tasks erledigt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: rgba(251, 191, 36, 0.2); color: #fbbf24;">
|
||||||
|
<i class="pi pi-clock"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.tasks.inProgress }}</div>
|
||||||
|
<div class="stat-label">In Bearbeitung</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: rgba(139, 92, 246, 0.2); color: #a78bfa;">
|
||||||
|
<i class="pi pi-folder"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.projects }}</div>
|
||||||
|
<div class="stat-label">Projekte</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<h3>Willkommen, {{ authStore.user?.username }}! 👋</h3>
|
||||||
|
<p>Das Agent Management System ist bereit. Verwalte deine Agents, Tasks und Projekte.</p>
|
||||||
|
<div class="quick-links">
|
||||||
|
<router-link to="/agents" class="quick-link">
|
||||||
|
<i class="pi pi-users"></i> Agents
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/tasks" class="quick-link">
|
||||||
|
<i class="pi pi-list-check"></i> Tasks
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/projects" class="quick-link">
|
||||||
|
<i class="pi pi-folder"></i> Projekte
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<h3>Letzte Aktivitäten</h3>
|
||||||
|
<div v-if="recentLogs.length === 0" class="no-logs">
|
||||||
|
Keine Logs in den letzten 24 Stunden
|
||||||
|
</div>
|
||||||
|
<div v-else class="recent-logs">
|
||||||
|
<div v-for="log in recentLogs" :key="log._id" class="log-item">
|
||||||
|
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
|
||||||
|
<span class="log-level" :class="log.level">{{ log.level }}</span>
|
||||||
|
<span class="log-msg">{{ log.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<router-link to="/logs" class="view-all">Alle Logs anzeigen →</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card p {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #818cf8;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-link:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-logs {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-logs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level.info { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
||||||
|
.log-level.warn { background: rgba(251, 191, 36, 0.2); color: #fbbf24; }
|
||||||
|
.log-level.error { background: rgba(239, 68, 68, 0.2); color: #f87171; }
|
||||||
|
.log-level.debug { background: rgba(107, 114, 128, 0.2); color: #9ca3af; }
|
||||||
|
|
||||||
|
.log-msg {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all {
|
||||||
|
display: block;
|
||||||
|
color: #818cf8;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1186
src/views/GitLabView.vue
Normal file
1186
src/views/GitLabView.vue
Normal file
File diff suppressed because it is too large
Load Diff
561
src/views/LabelsView.vue
Normal file
561
src/views/LabelsView.vue
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import Toast from 'primevue/toast'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
interface Label {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageInfo {
|
||||||
|
labelId: string
|
||||||
|
usage: {
|
||||||
|
assignments: number
|
||||||
|
tasks: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const labels = ref<Label[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// Create/Edit state
|
||||||
|
const createDialogVisible = ref(false)
|
||||||
|
const newLabelName = ref('')
|
||||||
|
const newLabelColor = ref('#6366f1')
|
||||||
|
const createLoading = ref(false)
|
||||||
|
const editingLabel = ref<Label | null>(null)
|
||||||
|
|
||||||
|
// Delete state
|
||||||
|
const deleteDialogVisible = ref(false)
|
||||||
|
const labelToDelete = ref<Label | null>(null)
|
||||||
|
const usageInfo = ref<UsageInfo | null>(null)
|
||||||
|
const usageLoading = ref(false)
|
||||||
|
const deleteAction = ref<'remove' | 'replace'>('remove')
|
||||||
|
const replacementLabelId = ref<string | null>(null)
|
||||||
|
const deleteLoading = ref(false)
|
||||||
|
|
||||||
|
const availableReplacements = computed(() =>
|
||||||
|
labels.value.filter((l) => l._id !== labelToDelete.value?._id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadLabels() {
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ labels: Label[] }>('/labels')
|
||||||
|
labels.value = data.labels || []
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Labels konnten nicht geladen werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
editingLabel.value = null
|
||||||
|
newLabelName.value = ''
|
||||||
|
newLabelColor.value = '#6366f1'
|
||||||
|
createDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(label: Label) {
|
||||||
|
editingLabel.value = label
|
||||||
|
newLabelName.value = label.name
|
||||||
|
newLabelColor.value = label.color
|
||||||
|
createDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLabel() {
|
||||||
|
if (!newLabelName.value.trim()) return
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
if (editingLabel.value) {
|
||||||
|
await api.put(`/labels/${editingLabel.value._id}`, { name: newLabelName.value.trim(), color: newLabelColor.value })
|
||||||
|
toast.add({ severity: 'success', summary: 'Aktualisiert', detail: `Label "${newLabelName.value}" aktualisiert`, life: 3000 })
|
||||||
|
} else {
|
||||||
|
await api.post('/labels', { name: newLabelName.value.trim(), color: newLabelColor.value })
|
||||||
|
toast.add({ severity: 'success', summary: 'Erstellt', detail: `Label "${newLabelName.value}" erstellt`, life: 3000 })
|
||||||
|
}
|
||||||
|
createDialogVisible.value = false
|
||||||
|
await loadLabels()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Label konnte nicht gespeichert werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDeleteDialog(label: Label) {
|
||||||
|
labelToDelete.value = label
|
||||||
|
usageInfo.value = null
|
||||||
|
deleteAction.value = 'remove'
|
||||||
|
replacementLabelId.value = null
|
||||||
|
usageLoading.value = true
|
||||||
|
deleteDialogVisible.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
usageInfo.value = await api.get<UsageInfo>(`/labels/${label._id}/usage`)
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Verwendung konnte nicht geprüft werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
usageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!labelToDelete.value) return
|
||||||
|
const id = labelToDelete.value._id
|
||||||
|
deleteLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = deleteAction.value === 'replace' && replacementLabelId.value
|
||||||
|
? `?replacementId=${replacementLabelId.value}`
|
||||||
|
: ''
|
||||||
|
await api.delete(`/labels/${id}${query}`)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gelöscht', detail: `Label "${labelToDelete.value.name}" wurde gelöscht`, life: 3000 })
|
||||||
|
deleteDialogVisible.value = false
|
||||||
|
await loadLabels()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Label konnte nicht gelöscht werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
deleteLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canConfirmDelete = computed(() => {
|
||||||
|
if (deleteAction.value === 'replace') return !!replacementLabelId.value
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadLabels)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="labels-view">
|
||||||
|
<Toast />
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h2><i class="pi pi-tag"></i> Labels</h2>
|
||||||
|
<button class="dialog-btn primary" @click="openCreateDialog">
|
||||||
|
<i class="pi pi-plus"></i> Neues Label
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Lade Labels...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="labels-grid">
|
||||||
|
<div v-for="label in labels" :key="label._id" class="label-card">
|
||||||
|
<span class="label-badge" :style="{ backgroundColor: label.color }">
|
||||||
|
{{ label.name }}
|
||||||
|
</span>
|
||||||
|
<div class="label-actions">
|
||||||
|
<button class="icon-btn-edit" @click="openEditDialog(label)" title="Bearbeiten">
|
||||||
|
<i class="pi pi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn-delete" @click="openDeleteDialog(label)" title="Löschen">
|
||||||
|
<i class="pi pi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="labels.length === 0" class="empty-state">
|
||||||
|
Keine Labels vorhanden.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="createDialogVisible"
|
||||||
|
:header="editingLabel ? 'Label bearbeiten' : 'Neues Label'"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '24rem' }"
|
||||||
|
>
|
||||||
|
<div class="create-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newLabelName"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="z.B. Feature, Bug, Dringend..."
|
||||||
|
@keyup.enter="createLabel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Farbe</label>
|
||||||
|
<div class="color-row">
|
||||||
|
<input type="color" v-model="newLabelColor" class="color-picker" />
|
||||||
|
<span class="label-badge" :style="{ backgroundColor: newLabelColor }">
|
||||||
|
{{ newLabelName || 'Vorschau' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<button class="dialog-btn" @click="createDialogVisible = false">Abbrechen</button>
|
||||||
|
<button
|
||||||
|
class="dialog-btn primary"
|
||||||
|
:disabled="!newLabelName.trim() || createLoading"
|
||||||
|
@click="saveLabel"
|
||||||
|
>
|
||||||
|
<i v-if="createLoading" class="pi pi-spin pi-spinner"></i>
|
||||||
|
{{ editingLabel ? 'Speichern' : 'Erstellen' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Delete Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="deleteDialogVisible"
|
||||||
|
header="Label löschen"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '28rem' }"
|
||||||
|
>
|
||||||
|
<div v-if="usageLoading" class="loading-state" style="padding: 1rem 0">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> Prüfe Verwendung...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="usageInfo" class="delete-content">
|
||||||
|
<p class="delete-question">
|
||||||
|
Label <strong>„{{ labelToDelete?.name }}"</strong> löschen?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="usageInfo.usage.total > 0" class="usage-section">
|
||||||
|
<div class="usage-warning">
|
||||||
|
<p class="usage-title">⚠️ Label wird verwendet</p>
|
||||||
|
<p class="usage-detail">
|
||||||
|
{{ usageInfo.usage.tasks }} Task(s), {{ usageInfo.usage.assignments }} Zuweisung(en)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-options">
|
||||||
|
<label class="action-option" :class="{ active: deleteAction === 'remove' }">
|
||||||
|
<input type="radio" v-model="deleteAction" value="remove" />
|
||||||
|
Label überall entfernen
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="action-option" :class="{ active: deleteAction === 'replace' }">
|
||||||
|
<input type="radio" v-model="deleteAction" value="replace" />
|
||||||
|
Durch anderes Label ersetzen
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-if="deleteAction === 'replace'"
|
||||||
|
v-model="replacementLabelId"
|
||||||
|
class="replacement-select"
|
||||||
|
>
|
||||||
|
<option :value="null" disabled>Label auswählen...</option>
|
||||||
|
<option v-for="l in availableReplacements" :key="l._id" :value="l._id">
|
||||||
|
{{ l.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="no-usage">
|
||||||
|
Dieses Label wird nirgends verwendet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button class="dialog-btn" @click="deleteDialogVisible = false">Abbrechen</button>
|
||||||
|
<button
|
||||||
|
class="dialog-btn danger"
|
||||||
|
:disabled="!canConfirmDelete || usageLoading || deleteLoading"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
<i v-if="deleteLoading" class="pi pi-spin pi-spinner"></i>
|
||||||
|
<i v-else class="pi pi-trash"></i>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.labels-view {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.labels-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-card:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-edit,
|
||||||
|
.icon-btn-delete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-edit:hover {
|
||||||
|
color: #a5b4fc;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-delete:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete Dialog Content */
|
||||||
|
.delete-content {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-question {
|
||||||
|
color: #e0e0e0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-question strong {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-warning {
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
border: 1px solid rgba(234, 179, 8, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-title {
|
||||||
|
color: #facc15;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-detail {
|
||||||
|
color: rgba(250, 204, 21, 0.7);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-option:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-option.active {
|
||||||
|
border-color: rgba(99, 102, 241, 0.4);
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-option input[type="radio"] {
|
||||||
|
accent-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replacement-select {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replacement-select:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replacement-select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-usage {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.danger {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-section {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Create Form */
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
width: 40px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
97
src/views/LoginView.vue
Normal file
97
src/views/LoginView.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const feVersion = import.meta.env.VITE_APP_VERSION || 'dev'
|
||||||
|
const beVersion = ref('...')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
const res = await fetch(`${apiBase}/version`)
|
||||||
|
const data = await res.json()
|
||||||
|
beVersion.value = data.version || 'unknown'
|
||||||
|
} catch {
|
||||||
|
beVersion.value = '?'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!email.value || !password.value) {
|
||||||
|
error.value = 'Bitte alle Felder ausfüllen'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.login(email.value, password.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Login fehlgeschlagen'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<h1>🤖 Agent Management</h1>
|
||||||
|
<p>Melde dich an, um fortzufahren</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-btn" :disabled="loading">
|
||||||
|
{{ loading ? 'Wird angemeldet...' : 'Anmelden' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="switch-mode">
|
||||||
|
Noch kein Konto? <router-link to="/register">Registrieren</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="version-info">
|
||||||
|
FE {{ feVersion }} | BE {{ beVersion }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
532
src/views/LogsView.vue
Normal file
532
src/views/LogsView.vue
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
_id: string
|
||||||
|
agentId: string
|
||||||
|
agentName: string
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug'
|
||||||
|
message: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const logs = ref<LogEntry[]>([])
|
||||||
|
const agents = ref<Agent[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const autoRefresh = ref(true)
|
||||||
|
let refreshInterval: number | null = null
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const filterAgent = ref('')
|
||||||
|
const filterLevel = ref('')
|
||||||
|
const filterHours = ref('24')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
interface LogStats {
|
||||||
|
overall: Record<string, number>
|
||||||
|
byAgent: Array<{ _id: string; agentName: string; total: number; levels: Array<{ level: string; count: number }> }>
|
||||||
|
}
|
||||||
|
const stats = ref<LogStats | null>(null)
|
||||||
|
|
||||||
|
// Detail
|
||||||
|
const selectedLog = ref<LogEntry | null>(null)
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filterAgent.value) params.set('agentId', filterAgent.value)
|
||||||
|
if (filterLevel.value) params.set('level', filterLevel.value)
|
||||||
|
if (filterHours.value) params.set('hours', filterHours.value)
|
||||||
|
params.set('limit', '200')
|
||||||
|
|
||||||
|
const [logsRes, agentsRes, statsRes] = await Promise.all([
|
||||||
|
api.get<{ logs: LogEntry[] }>(`/logs?${params}`),
|
||||||
|
agents.value.length ? Promise.resolve({ agents: agents.value }) : api.get<{ agents: Agent[] }>('/agents'),
|
||||||
|
api.get<LogStats>(`/logs/stats?hours=${filterHours.value}`)
|
||||||
|
])
|
||||||
|
|
||||||
|
logs.value = logsRes.logs
|
||||||
|
if (agentsRes.agents) agents.value = agentsRes.agents
|
||||||
|
stats.value = statsRes
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load logs:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredLogs = computed(() => {
|
||||||
|
if (!searchQuery.value) return logs.value
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return logs.value.filter(l =>
|
||||||
|
l.message.toLowerCase().includes(q) ||
|
||||||
|
l.agentName.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getAgentEmoji(agentId: string) {
|
||||||
|
const agent = agents.value.find(a => a._id === agentId)
|
||||||
|
return agent?.emoji || '🤖'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelColor(level: string) {
|
||||||
|
switch (level) {
|
||||||
|
case 'error': return '#ef4444'
|
||||||
|
case 'warn': return '#f59e0b'
|
||||||
|
case 'info': return '#3b82f6'
|
||||||
|
case 'debug': return '#6b7280'
|
||||||
|
default: return '#6b7280'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: string) {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp: string) {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const today = new Date()
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return 'Heute'
|
||||||
|
}
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Gestern'
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
autoRefresh.value = !autoRefresh.value
|
||||||
|
if (autoRefresh.value) {
|
||||||
|
startAutoRefresh()
|
||||||
|
} else {
|
||||||
|
stopAutoRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoRefresh() {
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval)
|
||||||
|
refreshInterval = setInterval(loadLogs, 10000) as unknown as number
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
refreshInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadLogs()
|
||||||
|
if (autoRefresh.value) startAutoRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopAutoRefresh()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h1>Logs</h1>
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
|
<button
|
||||||
|
@click="toggleAutoRefresh"
|
||||||
|
class="refresh-btn"
|
||||||
|
:class="{ active: autoRefresh }"
|
||||||
|
>
|
||||||
|
<i class="pi" :class="autoRefresh ? 'pi-pause' : 'pi-play'"></i>
|
||||||
|
{{ autoRefresh ? 'Auto' : 'Pausiert' }}
|
||||||
|
</button>
|
||||||
|
<button @click="loadLogs" class="refresh-btn">
|
||||||
|
<i class="pi pi-refresh" :class="{ 'pi-spin': loading }"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div v-if="stats" class="log-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-number">{{ (stats.overall.info || 0) + (stats.overall.warn || 0) + (stats.overall.error || 0) + (stats.overall.debug || 0) }}</span>
|
||||||
|
<span class="stat-label">Gesamt</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card error-card">
|
||||||
|
<span class="stat-number">{{ stats.overall.error || 0 }}</span>
|
||||||
|
<span class="stat-label">Errors</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card warn-card">
|
||||||
|
<span class="stat-number">{{ stats.overall.warn || 0 }}</span>
|
||||||
|
<span class="stat-label">Warnings</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card info-card">
|
||||||
|
<span class="stat-number">{{ stats.overall.info || 0 }}</span>
|
||||||
|
<span class="stat-label">Info</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filters">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="🔍 Suche in Logs..."
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
<select v-model="filterAgent" @change="loadLogs">
|
||||||
|
<option value="">Alle Agents</option>
|
||||||
|
<option v-for="agent in agents" :key="agent._id" :value="agent._id">
|
||||||
|
{{ agent.emoji }} {{ agent.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select v-model="filterLevel" @change="loadLogs">
|
||||||
|
<option value="">Alle Level</option>
|
||||||
|
<option value="error">🔴 Error</option>
|
||||||
|
<option value="warn">🟡 Warning</option>
|
||||||
|
<option value="info">🔵 Info</option>
|
||||||
|
<option value="debug">⚪ Debug</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select v-model="filterHours" @change="loadLogs">
|
||||||
|
<option value="1">Letzte Stunde</option>
|
||||||
|
<option value="6">Letzte 6 Stunden</option>
|
||||||
|
<option value="24">Letzte 24 Stunden</option>
|
||||||
|
<option value="72">Letzte 3 Tage</option>
|
||||||
|
<option value="168">Letzte Woche</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logs Table -->
|
||||||
|
<div class="logs-container">
|
||||||
|
<div v-if="loading && logs.length === 0" class="loading">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="logs.length === 0" class="empty">
|
||||||
|
<i class="pi pi-inbox" style="font-size: 3rem; color: rgba(255,255,255,0.2);"></i>
|
||||||
|
<p>Keine Logs gefunden</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="logs-list">
|
||||||
|
<div class="log-count">{{ filteredLogs.length }} Einträge</div>
|
||||||
|
<div
|
||||||
|
v-for="log in filteredLogs"
|
||||||
|
:key="log._id"
|
||||||
|
class="log-entry"
|
||||||
|
:class="[log.level, { selected: selectedLog?._id === log._id }]"
|
||||||
|
@click="selectedLog = selectedLog?._id === log._id ? null : log"
|
||||||
|
>
|
||||||
|
<div class="log-time">
|
||||||
|
<span class="log-date">{{ formatDate(log.timestamp) }}</span>
|
||||||
|
<span class="log-timestamp">{{ formatTime(log.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-level" :style="{ color: getLevelColor(log.level) }">
|
||||||
|
{{ log.level.toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div class="log-agent">
|
||||||
|
{{ getAgentEmoji(log.agentId) }} {{ log.agentName }}
|
||||||
|
</div>
|
||||||
|
<div class="log-message">{{ log.message }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail Panel -->
|
||||||
|
<div v-if="selectedLog" class="log-detail">
|
||||||
|
<div class="detail-header">
|
||||||
|
<h4>Log Detail</h4>
|
||||||
|
<button class="close-btn" @click="selectedLog = null"><i class="pi pi-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Zeitpunkt</span>
|
||||||
|
<span>{{ new Date(selectedLog.timestamp).toLocaleString('de-DE') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Level</span>
|
||||||
|
<span :style="{ color: getLevelColor(selectedLog.level) }">{{ selectedLog.level.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Agent</span>
|
||||||
|
<span>{{ getAgentEmoji(selectedLog.agentId) }} {{ selectedLog.agentName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Nachricht</span>
|
||||||
|
<pre class="detail-message">{{ selectedLog.message }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedLog.metadata && Object.keys(selectedLog.metadata).length" class="detail-row">
|
||||||
|
<span class="detail-label">Metadata</span>
|
||||||
|
<pre class="detail-metadata">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">ID</span>
|
||||||
|
<span class="mono">{{ selectedLog._id }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.error-card { border-color: rgba(239, 68, 68, 0.3); }
|
||||||
|
.stat-card.warn-card { border-color: rgba(245, 158, 11, 0.3); }
|
||||||
|
.stat-card.info-card { border-color: rgba(59, 130, 246, 0.3); }
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.error-card .stat-number { color: #f87171; }
|
||||||
|
.stat-card.warn-card .stat-number { color: #fbbf24; }
|
||||||
|
.stat-card.info-card .stat-number { color: #60a5fa; }
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder { color: rgba(255, 255, 255, 0.4); }
|
||||||
|
.search-input:focus { outline: none; border-color: #6366f1; }
|
||||||
|
|
||||||
|
.log-count {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.selected {
|
||||||
|
background: rgba(99, 102, 241, 0.15) !important;
|
||||||
|
border-left: 2px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry { cursor: pointer; }
|
||||||
|
|
||||||
|
.log-detail {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header h4 { margin: 0; color: rgba(255, 255, 255, 0.8); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover { color: #fff; }
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-message {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-metadata {
|
||||||
|
margin: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #a78bfa;
|
||||||
|
font-family: monospace;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: monospace; font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); }
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters select {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn.active {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-container {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: calc(100vh - 280px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-list {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 60px 140px 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.error {
|
||||||
|
background: rgba(239, 68, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.warn {
|
||||||
|
background: rgba(251, 191, 36, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-date {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-agent {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.log-entry {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
671
src/views/ProjectsView.vue
Normal file
671
src/views/ProjectsView.vue
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
_id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
project?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLabProjectRef {
|
||||||
|
projectId: number
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
color: string
|
||||||
|
rules?: string
|
||||||
|
gitlabProjects?: GitLabProjectRef[]
|
||||||
|
gitlabProjectId?: number
|
||||||
|
gitlabUrl?: string
|
||||||
|
gitlabPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitLabProject {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
fullName: string
|
||||||
|
path: string
|
||||||
|
description: string | null
|
||||||
|
webUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const gitlabProjects = ref<GitLabProject[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const editingProject = ref<Project | null>(null)
|
||||||
|
|
||||||
|
const newProject = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: '#6366f1',
|
||||||
|
rules: '',
|
||||||
|
gitlabProjects: [] as GitLabProjectRef[]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [projectsRes, tasksRes] = await Promise.all([
|
||||||
|
api.get<{ projects: Project[] }>('/tasks/projects/list'),
|
||||||
|
api.get<{ tasks: Task[] }>('/tasks')
|
||||||
|
])
|
||||||
|
projects.value = projectsRes.projects
|
||||||
|
tasks.value = tasksRes.tasks
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Daten konnten nicht geladen werden', life: 5000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGitlabProjects() {
|
||||||
|
try {
|
||||||
|
gitlabProjects.value = await api.get('/gitlab/projects')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load GitLab projects:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedGitlabIds = ref<number[]>([])
|
||||||
|
|
||||||
|
function initGitlabSelection() {
|
||||||
|
if (!editingProject.value) return
|
||||||
|
if (editingProject.value.gitlabProjects?.length) {
|
||||||
|
selectedGitlabIds.value = editingProject.value.gitlabProjects.map(p => p.projectId)
|
||||||
|
} else if (editingProject.value.gitlabProjectId) {
|
||||||
|
selectedGitlabIds.value = [editingProject.value.gitlabProjectId]
|
||||||
|
} else {
|
||||||
|
selectedGitlabIds.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGitlabProject(gitlabProject: GitLabProject) {
|
||||||
|
const idx = selectedGitlabIds.value.indexOf(gitlabProject.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
selectedGitlabIds.value.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
selectedGitlabIds.value.push(gitlabProject.id)
|
||||||
|
}
|
||||||
|
updateGitlabProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGitlabProjects() {
|
||||||
|
const glProjects = selectedGitlabIds.value.map(id => {
|
||||||
|
const gp = gitlabProjects.value.find(p => p.id === id)
|
||||||
|
return gp ? {
|
||||||
|
projectId: gp.id,
|
||||||
|
path: gp.path,
|
||||||
|
url: gp.webUrl,
|
||||||
|
name: gp.fullName
|
||||||
|
} : null
|
||||||
|
}).filter(Boolean) as GitLabProjectRef[]
|
||||||
|
|
||||||
|
if (editingProject.value) {
|
||||||
|
editingProject.value.gitlabProjects = glProjects
|
||||||
|
} else {
|
||||||
|
newProject.value.gitlabProjects = glProjects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitlabSelected(id: number): boolean {
|
||||||
|
return selectedGitlabIds.value.includes(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectTasks(projectId: string) {
|
||||||
|
return tasks.value.filter(t => t.project === projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskStats(projectId: string) {
|
||||||
|
const projectTasks = getProjectTasks(projectId)
|
||||||
|
return {
|
||||||
|
total: projectTasks.length,
|
||||||
|
done: projectTasks.filter(t => t.status === 'done').length,
|
||||||
|
inProgress: projectTasks.filter(t => t.status === 'in_progress').length,
|
||||||
|
todo: projectTasks.filter(t => t.status === 'todo' || t.status === 'backlog').length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
newProject.value = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
color: '#6366f1',
|
||||||
|
rules: '',
|
||||||
|
gitlabProjects: []
|
||||||
|
}
|
||||||
|
selectedGitlabIds.value = []
|
||||||
|
showCreateModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editProject(project: Project) {
|
||||||
|
editingProject.value = { ...project }
|
||||||
|
initGitlabSelection()
|
||||||
|
showEditModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject() {
|
||||||
|
if (!newProject.value.name.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Fehler', detail: 'Name ist erforderlich', life: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/tasks/projects', {
|
||||||
|
name: newProject.value.name,
|
||||||
|
description: newProject.value.description,
|
||||||
|
color: newProject.value.color,
|
||||||
|
rules: newProject.value.rules,
|
||||||
|
gitlabProjects: newProject.value.gitlabProjects
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'Projekt wurde angelegt', life: 3000 })
|
||||||
|
showCreateModal.value = false
|
||||||
|
await loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProject() {
|
||||||
|
if (!editingProject.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put(`/tasks/projects/${editingProject.value._id}`, {
|
||||||
|
name: editingProject.value.name,
|
||||||
|
description: editingProject.value.description,
|
||||||
|
color: editingProject.value.color,
|
||||||
|
rules: editingProject.value.rules,
|
||||||
|
gitlabProjects: editingProject.value.gitlabProjects,
|
||||||
|
gitlabProjectId: editingProject.value.gitlabProjects?.[0]?.projectId,
|
||||||
|
gitlabUrl: editingProject.value.gitlabProjects?.[0]?.url,
|
||||||
|
gitlabPath: editingProject.value.gitlabProjects?.[0]?.path
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Projekt aktualisiert', life: 3000 })
|
||||||
|
showEditModal.value = false
|
||||||
|
await loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject(id: string, name: string) {
|
||||||
|
if (!confirm(`Projekt "${name}" wirklich löschen?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/tasks/projects/${id}`)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gelöscht', detail: 'Projekt wurde entfernt', life: 3000 })
|
||||||
|
await loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
loadGitlabProjects()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Projekte</h1>
|
||||||
|
<button v-if="authStore.user?.role === 'admin'" @click="openCreateModal" class="create-btn">
|
||||||
|
<i class="pi pi-plus"></i> Neues Projekt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="projects-grid">
|
||||||
|
<div v-for="project in projects" :key="project._id" class="project-card">
|
||||||
|
<div class="project-header" :style="{ borderColor: project.color }">
|
||||||
|
<div class="project-color" :style="{ background: project.color }"></div>
|
||||||
|
<div class="project-info">
|
||||||
|
<h3>{{ project.name }}</h3>
|
||||||
|
<p v-if="project.description">{{ project.description }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="authStore.user?.role === 'admin'"
|
||||||
|
@click="editProject(project)"
|
||||||
|
class="edit-btn"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{{ getTaskStats(project._id).total }}</span>
|
||||||
|
<span class="stat-label">Tasks</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat done">
|
||||||
|
<span class="stat-value">{{ getTaskStats(project._id).done }}</span>
|
||||||
|
<span class="stat-label">Erledigt</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat progress">
|
||||||
|
<span class="stat-value">{{ getTaskStats(project._id).inProgress }}</span>
|
||||||
|
<span class="stat-label">In Arbeit</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat todo">
|
||||||
|
<span class="stat-value">{{ getTaskStats(project._id).todo }}</span>
|
||||||
|
<span class="stat-label">Offen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="project.gitlabProjects?.length || project.gitlabPath" class="project-gitlab">
|
||||||
|
<h4><i class="pi pi-github"></i> GitLab</h4>
|
||||||
|
<template v-if="project.gitlabProjects?.length">
|
||||||
|
<div v-for="gp in project.gitlabProjects" :key="gp.projectId" class="gitlab-project-item">
|
||||||
|
<a :href="gp.url" target="_blank" class="gitlab-link">
|
||||||
|
<i class="pi pi-external-link"></i>
|
||||||
|
{{ gp.path }}
|
||||||
|
</a>
|
||||||
|
<span class="gitlab-id">ID: {{ gp.projectId }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="project.gitlabPath || project.gitlabUrl">
|
||||||
|
<a v-if="project.gitlabUrl" :href="project.gitlabUrl" target="_blank" class="gitlab-link">
|
||||||
|
<i class="pi pi-external-link"></i>
|
||||||
|
{{ project.gitlabPath || project.gitlabUrl }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="project.rules" class="project-rules">
|
||||||
|
<h4><i class="pi pi-book"></i> Regeln</h4>
|
||||||
|
<p>{{ project.rules }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-tasks">
|
||||||
|
<h4>Aktuelle Tasks</h4>
|
||||||
|
<div v-if="getProjectTasks(project._id).length === 0" class="no-tasks">
|
||||||
|
Keine Tasks
|
||||||
|
</div>
|
||||||
|
<div v-else class="task-list">
|
||||||
|
<div
|
||||||
|
v-for="task in getProjectTasks(project._id).slice(0, 5)"
|
||||||
|
:key="task._id"
|
||||||
|
class="task-item"
|
||||||
|
>
|
||||||
|
<span class="task-status" :class="task.status"></span>
|
||||||
|
<span class="task-title">{{ task.title }}</span>
|
||||||
|
<span class="task-priority" :class="task.priority">{{ task.priority }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="getProjectTasks(project._id).length > 5" class="more-tasks">
|
||||||
|
+{{ getProjectTasks(project._id).length - 5 }} weitere
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-link :to="`/tasks?project=${project._id}`" class="view-all-btn">
|
||||||
|
Alle Tasks anzeigen →
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projects.length === 0" class="empty-state">
|
||||||
|
<i class="pi pi-folder-open"></i>
|
||||||
|
<p>Noch keine Projekte vorhanden</p>
|
||||||
|
<button v-if="authStore.user?.role === 'admin'" @click="openCreateModal" class="create-btn">
|
||||||
|
<i class="pi pi-plus"></i> Erstes Projekt anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Project Modal -->
|
||||||
|
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Neues Projekt</h2>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input v-model="newProject.name" type="text" placeholder="Projektname" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<input v-model="newProject.description" type="text" placeholder="Kurze Beschreibung" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Farbe</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button
|
||||||
|
v-for="color in ['#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f59e0b', '#22c55e', '#06b6d4', '#3b82f6']"
|
||||||
|
:key="color"
|
||||||
|
@click="newProject.color = color"
|
||||||
|
class="color-btn"
|
||||||
|
:class="{ active: newProject.color === color }"
|
||||||
|
:style="{ background: color }"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Regeln / Hinweise</label>
|
||||||
|
<textarea v-model="newProject.rules" rows="3" placeholder="z.B. Code-Review erforderlich..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="gitlabProjects.length > 0" class="gitlab-section">
|
||||||
|
<h4><i class="pi pi-github"></i> GitLab Projekte</h4>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Verknüpfte Projekte (optional)</label>
|
||||||
|
<div class="gitlab-multi-select">
|
||||||
|
<div
|
||||||
|
v-for="gp in gitlabProjects"
|
||||||
|
:key="gp.id"
|
||||||
|
class="gitlab-option"
|
||||||
|
:class="{ selected: isGitlabSelected(gp.id) }"
|
||||||
|
@click="toggleGitlabProject(gp)"
|
||||||
|
>
|
||||||
|
<i :class="isGitlabSelected(gp.id) ? 'pi pi-check-square' : 'pi pi-stop'" class="checkbox-icon"></i>
|
||||||
|
<span class="gitlab-name">{{ gp.fullName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="showCreateModal = false" class="cancel-btn">Abbrechen</button>
|
||||||
|
<button @click="createProject" class="submit-btn">Projekt erstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Project Modal -->
|
||||||
|
<div v-if="showEditModal && editingProject" class="modal-overlay" @click.self="showEditModal = false">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Projekt bearbeiten</h2>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input v-model="editingProject.name" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<input v-model="editingProject.description" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Farbe</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button
|
||||||
|
v-for="color in ['#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f59e0b', '#22c55e', '#06b6d4', '#3b82f6']"
|
||||||
|
:key="color"
|
||||||
|
@click="editingProject.color = color"
|
||||||
|
class="color-btn"
|
||||||
|
:class="{ active: editingProject.color === color }"
|
||||||
|
:style="{ background: color }"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Regeln / Hinweise</label>
|
||||||
|
<textarea v-model="editingProject.rules" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="gitlabProjects.length > 0" class="gitlab-section">
|
||||||
|
<h4><i class="pi pi-github"></i> GitLab Projekte</h4>
|
||||||
|
<div class="form-field">
|
||||||
|
<label>Verknüpfte Projekte</label>
|
||||||
|
<div class="gitlab-multi-select">
|
||||||
|
<div
|
||||||
|
v-for="gp in gitlabProjects"
|
||||||
|
:key="gp.id"
|
||||||
|
class="gitlab-option"
|
||||||
|
:class="{ selected: isGitlabSelected(gp.id) }"
|
||||||
|
@click="toggleGitlabProject(gp)"
|
||||||
|
>
|
||||||
|
<i :class="isGitlabSelected(gp.id) ? 'pi pi-check-square' : 'pi pi-stop'" class="checkbox-icon"></i>
|
||||||
|
<span class="gitlab-name">{{ gp.fullName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button @click="deleteProject(editingProject._id, editingProject.name)" class="delete-btn">
|
||||||
|
<i class="pi pi-trash"></i> Löschen
|
||||||
|
</button>
|
||||||
|
<div style="flex: 1;"></div>
|
||||||
|
<button @click="showEditModal = false" class="cancel-btn">Abbrechen</button>
|
||||||
|
<button @click="saveProject" class="submit-btn">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 { margin: 0; }
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-bottom: 3px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-color {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info { flex: 1; }
|
||||||
|
.project-info h3 { margin: 0; color: #fff; font-size: 1.25rem; }
|
||||||
|
.project-info p { margin: 0.25rem 0 0; color: rgba(255,255,255,0.6); font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||||
|
|
||||||
|
.project-stats {
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
gap: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat { text-align: center; flex: 1; }
|
||||||
|
.stat-value { display: block; font-size: 1.5rem; font-weight: 700; color: #fff; }
|
||||||
|
.stat-label { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
|
||||||
|
.stat.done .stat-value { color: #4ade80; }
|
||||||
|
.stat.progress .stat-value { color: #fbbf24; }
|
||||||
|
.stat.todo .stat-value { color: #60a5fa; }
|
||||||
|
|
||||||
|
.project-rules, .project-gitlab {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-rules h4, .project-gitlab h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-gitlab h4 { color: #fb923c; }
|
||||||
|
.project-rules p { margin: 0; font-size: 0.875rem; color: rgba(255,255,255,0.6); }
|
||||||
|
|
||||||
|
.project-tasks { padding: 1rem 1.25rem; }
|
||||||
|
.project-tasks h4 { margin: 0 0 0.75rem; font-size: 0.875rem; color: rgba(255,255,255,0.7); }
|
||||||
|
|
||||||
|
.task-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.task-item { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.task-status { width: 8px; height: 8px; border-radius: 50%; background: #64748b; }
|
||||||
|
.task-status.done { background: #4ade80; }
|
||||||
|
.task-status.in_progress { background: #fbbf24; }
|
||||||
|
.task-status.review { background: #a78bfa; }
|
||||||
|
.task-status.todo { background: #60a5fa; }
|
||||||
|
.task-status.backlog { background: #64748b; }
|
||||||
|
|
||||||
|
.task-title { flex: 1; color: rgba(255,255,255,0.8); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.task-priority { font-size: 0.7rem; padding: 0.125rem 0.5rem; border-radius: 4px; text-transform: uppercase; background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6); }
|
||||||
|
.task-priority.urgent { background: rgba(239,68,68,0.2); color: #f87171; }
|
||||||
|
.task-priority.high { background: rgba(251,191,36,0.2); color: #fbbf24; }
|
||||||
|
|
||||||
|
.no-tasks, .more-tasks { color: rgba(255,255,255,0.4); font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.view-all-btn {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #818cf8;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-btn:hover { background: rgba(99,102,241,0.1); }
|
||||||
|
|
||||||
|
.loading, .empty-state { text-align: center; padding: 4rem; color: rgba(255,255,255,0.6); }
|
||||||
|
.empty-state i { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
|
||||||
|
.empty-state p { margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.gitlab-link { display: inline-flex; align-items: center; gap: 0.375rem; color: #fb923c; text-decoration: none; font-size: 0.875rem; font-family: monospace; }
|
||||||
|
.gitlab-link:hover { text-decoration: underline; }
|
||||||
|
.gitlab-id { margin-left: 0.75rem; color: rgba(255,255,255,0.4); font-size: 0.75rem; }
|
||||||
|
.gitlab-project-item { display: flex; align-items: center; padding: 0.25rem 0; }
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 { margin: 0 0 1rem; color: #fff; }
|
||||||
|
.modal-body { flex: 1; overflow-y: auto; padding-right: 0.5rem; }
|
||||||
|
|
||||||
|
.form-field { margin-bottom: 1rem; }
|
||||||
|
.form-field label { display: block; margin-bottom: 0.5rem; color: rgba(255,255,255,0.7); font-size: 0.875rem; }
|
||||||
|
.form-field input, .form-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-btn { width: 32px; height: 32px; border-radius: 8px; border: 2px solid transparent; cursor: pointer; }
|
||||||
|
.color-btn.active { border-color: #fff; transform: scale(1.1); }
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn { padding: 0.5rem 1rem; background: transparent; border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; color: #f87171; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.delete-btn:hover { background: rgba(239,68,68,0.2); }
|
||||||
|
|
||||||
|
.cancel-btn { padding: 0.75rem 1rem; background: transparent; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; color: rgba(255,255,255,0.7); cursor: pointer; }
|
||||||
|
.submit-btn { padding: 0.75rem 1.5rem; background: linear-gradient(135deg, #6366f1, #8b5cf6); border: none; border-radius: 8px; color: #fff; font-weight: 600; cursor: pointer; }
|
||||||
|
|
||||||
|
.gitlab-section { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); }
|
||||||
|
.gitlab-section h4 { margin: 0 0 1rem; color: #fb923c; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.gitlab-multi-select { max-height: 150px; overflow-y: auto; border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(255,255,255,0.02); }
|
||||||
|
.gitlab-option { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem; cursor: pointer; transition: background 0.2s; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||||
|
.gitlab-option:last-child { border-bottom: none; }
|
||||||
|
.gitlab-option:hover { background: rgba(255,255,255,0.05); }
|
||||||
|
.gitlab-option.selected { background: rgba(99,102,241,0.2); }
|
||||||
|
.checkbox-icon { color: rgba(255,255,255,0.4); font-size: 1rem; }
|
||||||
|
.gitlab-option.selected .checkbox-icon { color: #6366f1; }
|
||||||
|
.gitlab-name { font-size: 0.8rem; color: rgba(255,255,255,0.9); }
|
||||||
|
</style>
|
||||||
143
src/views/RegisterView.vue
Normal file
143
src/views/RegisterView.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const registrationDisabled = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const settings = await api.get<{ allowRegistration: boolean }>('/settings')
|
||||||
|
registrationDisabled.value = !settings.allowRegistration
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check registration status')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
if (!email.value || !username.value || !password.value) {
|
||||||
|
error.value = 'Bitte alle Felder ausfüllen'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = 'Passwörter stimmen nicht überein'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
error.value = 'Passwort muss mindestens 8 Zeichen haben'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.register(email.value, username.value, password.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Registrierung fehlgeschlagen'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<h1>🤖 Agent Management</h1>
|
||||||
|
<p>Erstelle ein neues Konto</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="registrationDisabled" class="info-message">
|
||||||
|
<i class="pi pi-lock" style="margin-right: 0.5rem;"></i>
|
||||||
|
Registrierung ist derzeit deaktiviert. Kontaktiere einen Administrator.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-if="!registrationDisabled" @submit.prevent="handleRegister">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="dein_name"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="confirmPassword">Passwort bestätigen</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-btn" :disabled="loading">
|
||||||
|
{{ loading ? 'Wird registriert...' : 'Registrieren' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="switch-mode">
|
||||||
|
Bereits registriert? <router-link to="/login">Anmelden</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-message {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #a5b4fc;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1532
src/views/SecretsView.vue
Normal file
1532
src/views/SecretsView.vue
Normal file
File diff suppressed because it is too large
Load Diff
671
src/views/SettingsView.vue
Normal file
671
src/views/SettingsView.vue
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useWebSocketStore } from '../stores/websocket'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const wsStore = useWebSocketStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// WebSocket Stats
|
||||||
|
interface ServerStats {
|
||||||
|
startedAt: string
|
||||||
|
totalConnections: number
|
||||||
|
totalDisconnections: number
|
||||||
|
totalBroadcasts: number
|
||||||
|
totalMessagesSent: number
|
||||||
|
totalMessagesReceived: number
|
||||||
|
currentConnections: number
|
||||||
|
users: Array<{
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
connectedAt: string
|
||||||
|
messagesSent: number
|
||||||
|
messagesReceived: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverStats = ref<ServerStats | null>(null)
|
||||||
|
let wsRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const wsUptime = computed(() => {
|
||||||
|
const connAt = wsStore.stats.connectedAt
|
||||||
|
if (!connAt) return '—'
|
||||||
|
const diff = Date.now() - new Date(connAt).getTime()
|
||||||
|
const secs = Math.floor(diff / 1000)
|
||||||
|
if (secs < 60) return `${secs}s`
|
||||||
|
const mins = Math.floor(secs / 60)
|
||||||
|
if (mins < 60) return `${mins}m ${secs % 60}s`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
return `${hrs}h ${mins % 60}m`
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverUptime = computed(() => {
|
||||||
|
if (!serverStats.value?.startedAt) return '—'
|
||||||
|
const diff = Date.now() - new Date(serverStats.value.startedAt).getTime()
|
||||||
|
const secs = Math.floor(diff / 1000)
|
||||||
|
if (secs < 60) return `${secs}s`
|
||||||
|
const mins = Math.floor(secs / 60)
|
||||||
|
if (mins < 60) return `${mins}m`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ${mins % 60}m`
|
||||||
|
const days = Math.floor(hrs / 24)
|
||||||
|
return `${days}d ${hrs % 24}h`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Export/Import
|
||||||
|
const exportStats = ref<Record<string, number> | null>(null)
|
||||||
|
const exportLoading = ref(false)
|
||||||
|
const importLoading = ref(false)
|
||||||
|
|
||||||
|
async function loadExportStats() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ stats: Record<string, number> }>('/data/stats')
|
||||||
|
exportStats.value = res.stats
|
||||||
|
} catch {
|
||||||
|
// Not admin or endpoint not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportData() {
|
||||||
|
exportLoading.value = true
|
||||||
|
try {
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
const token = localStorage.getItem('ams_token')
|
||||||
|
const res = await fetch(`${apiBase}/data/export`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `ams-export-${new Date().toISOString().slice(0, 10)}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.add({ severity: 'success', summary: 'Export', detail: 'Daten exportiert', life: 3000 })
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Export fehlgeschlagen', life: 5000 })
|
||||||
|
} finally {
|
||||||
|
exportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importData(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
importLoading.value = true
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const json = JSON.parse(text)
|
||||||
|
const data = json.data || json
|
||||||
|
|
||||||
|
const res = await api.post<{ results: Record<string, { inserted: number; skipped: number }> }>('/data/import', {
|
||||||
|
data,
|
||||||
|
mode: 'merge'
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = Object.entries(res.results)
|
||||||
|
.map(([col, r]) => `${col}: ${r.inserted} importiert, ${r.skipped} übersprungen`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
toast.add({ severity: 'success', summary: 'Import', detail: summary, life: 8000 })
|
||||||
|
await loadExportStats()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: `Import fehlgeschlagen: ${message}`, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
importLoading.value = false
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWsStats() {
|
||||||
|
try {
|
||||||
|
const data = await api.get<ServerStats>('/ws/status')
|
||||||
|
serverStats.value = data
|
||||||
|
} catch {
|
||||||
|
serverStats.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const feVersion = import.meta.env.VITE_APP_VERSION || 'dev'
|
||||||
|
const beVersion = ref('...')
|
||||||
|
|
||||||
|
// User Provider Settings
|
||||||
|
const userSettings = ref({
|
||||||
|
sttProvider: 'openai' as 'openai' | 'custom',
|
||||||
|
openaiApiKey: '',
|
||||||
|
customSttUrl: '',
|
||||||
|
telegramBotToken: '',
|
||||||
|
telegramDefaultChatId: '',
|
||||||
|
})
|
||||||
|
const savingUserSettings = ref(false)
|
||||||
|
|
||||||
|
async function loadUserSettings() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ settings: typeof userSettings.value }>('/user-settings')
|
||||||
|
userSettings.value = res.settings
|
||||||
|
} catch {
|
||||||
|
// Keine Settings vorhanden — Defaults beibehalten
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserSettings() {
|
||||||
|
savingUserSettings.value = true
|
||||||
|
try {
|
||||||
|
await api.put('/user-settings', userSettings.value)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Provider-Einstellungen aktualisiert', life: 3000 })
|
||||||
|
await loadUserSettings()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
savingUserSettings.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowRegistration = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// Code Theme Settings
|
||||||
|
const codeThemes = [
|
||||||
|
{ value: 'github-dark', label: 'GitHub Dark' },
|
||||||
|
{ value: 'github-dark-dimmed', label: 'GitHub Dark Dimmed' },
|
||||||
|
{ value: 'monokai', label: 'Monokai' },
|
||||||
|
{ value: 'dracula', label: 'Dracula' },
|
||||||
|
{ value: 'atom-one-dark', label: 'Atom One Dark' },
|
||||||
|
{ value: 'vs2015', label: 'VS 2015 Dark' },
|
||||||
|
{ value: 'nord', label: 'Nord' },
|
||||||
|
{ value: 'tokyo-night-dark', label: 'Tokyo Night' },
|
||||||
|
{ value: 'agate', label: 'Agate' },
|
||||||
|
{ value: 'androidstudio', label: 'Android Studio' },
|
||||||
|
{ value: 'hybrid', label: 'Hybrid' },
|
||||||
|
{ value: 'obsidian', label: 'Obsidian' },
|
||||||
|
{ value: 'stackoverflow-dark', label: 'Stack Overflow Dark' },
|
||||||
|
{ value: 'night-owl', label: 'Night Owl' },
|
||||||
|
{ value: 'felipec', label: 'Felipec' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedCodeTheme = ref(localStorage.getItem('ams_code_theme') || 'github-dark')
|
||||||
|
|
||||||
|
function updateCodeTheme() {
|
||||||
|
localStorage.setItem('ams_code_theme', selectedCodeTheme.value)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Code-Theme aktualisiert', life: 3000 })
|
||||||
|
// Dispatch event so GitLabView can react
|
||||||
|
window.dispatchEvent(new CustomEvent('code-theme-changed', { detail: selectedCodeTheme.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ allowRegistration: boolean }>('/settings')
|
||||||
|
allowRegistration.value = data.allowRegistration
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load settings:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRegistration() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await api.put('/settings', { allowRegistration: allowRegistration.value })
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Einstellungen aktualisiert', life: 3000 })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
// Revert on error
|
||||||
|
allowRegistration.value = !allowRegistration.value
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadSettings()
|
||||||
|
fetchWsStats()
|
||||||
|
loadExportStats()
|
||||||
|
loadUserSettings()
|
||||||
|
wsRefreshTimer = setInterval(fetchWsStats, 10000)
|
||||||
|
try {
|
||||||
|
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
const res = await fetch(`${apiBase}/version`)
|
||||||
|
const data = await res.json()
|
||||||
|
beVersion.value = data.version || 'unknown'
|
||||||
|
} catch {
|
||||||
|
beVersion.value = '?'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (wsRefreshTimer) clearInterval(wsRefreshTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Einstellungen</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Profil</h3>
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Benutzername</div>
|
||||||
|
<div class="setting-value">{{ authStore.user?.username }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">E-Mail</div>
|
||||||
|
<div class="setting-value">{{ authStore.user?.email }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Rolle</div>
|
||||||
|
<div class="setting-value">{{ authStore.user?.role }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="pi pi-code" style="margin-right: 0.5rem;"></i>Code Editor</h3>
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Syntax-Highlighting Theme</div>
|
||||||
|
<div class="setting-description">Farbschema für Code-Anzeige in GitLab</div>
|
||||||
|
</div>
|
||||||
|
<select v-model="selectedCodeTheme" @change="updateCodeTheme" class="theme-select">
|
||||||
|
<option v-for="theme in codeThemes" :key="theme.value" :value="theme.value">
|
||||||
|
{{ theme.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="pi pi-key" style="margin-right: 0.5rem;"></i>Provider & API-Keys</h3>
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">STT Provider</div>
|
||||||
|
<div class="setting-description">Speech-to-Text Anbieter für Spracheingabe</div>
|
||||||
|
</div>
|
||||||
|
<select v-model="userSettings.sttProvider" class="theme-select" style="min-width: 160px;">
|
||||||
|
<option value="openai">OpenAI Whisper</option>
|
||||||
|
<option value="custom">Custom URL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">OpenAI API-Key</div>
|
||||||
|
<div class="setting-description">Für Whisper Transkription</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="userSettings.openaiApiKey"
|
||||||
|
type="password"
|
||||||
|
class="theme-select"
|
||||||
|
style="min-width: 280px;"
|
||||||
|
placeholder="sk-..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="userSettings.sttProvider === 'custom'" class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Custom STT URL</div>
|
||||||
|
<div class="setting-description">Eigener Transkriptions-Endpunkt</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="userSettings.customSttUrl"
|
||||||
|
class="theme-select"
|
||||||
|
style="min-width: 280px;"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Telegram Bot-Token</div>
|
||||||
|
<div class="setting-description">Für das Senden von Aufgaben</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="userSettings.telegramBotToken"
|
||||||
|
type="password"
|
||||||
|
class="theme-select"
|
||||||
|
style="min-width: 280px;"
|
||||||
|
placeholder="123456:ABC..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Telegram Chat-ID</div>
|
||||||
|
<div class="setting-description">Standard-Chat für Nachrichten</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="userSettings.telegramDefaultChatId"
|
||||||
|
class="theme-select"
|
||||||
|
style="min-width: 280px;"
|
||||||
|
placeholder="z.B. 7150608398"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="justify-content: flex-end;">
|
||||||
|
<button class="export-btn" @click="saveUserSettings" :disabled="savingUserSettings">
|
||||||
|
<i :class="savingUserSettings ? 'pi pi-spin pi-spinner' : 'pi pi-save'" style="margin-right: 0.4rem;"></i>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="pi pi-info-circle" style="margin-right: 0.5rem;"></i>Version</h3>
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Frontend</div>
|
||||||
|
<div class="setting-value">{{ feVersion }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Backend</div>
|
||||||
|
<div class="setting-value">{{ beVersion }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authStore.user?.role === 'admin'" class="settings-section">
|
||||||
|
<h3><i class="pi pi-download" style="margin-right: 0.5rem;"></i>Daten Export / Import</h3>
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="setting-row" v-if="exportStats">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Datenbestand</div>
|
||||||
|
<div class="setting-description">
|
||||||
|
{{ exportStats.tasks || 0 }} Tasks, {{ exportStats.projects || 0 }} Projekte,
|
||||||
|
{{ exportStats.agents || 0 }} Agents, {{ exportStats.labels || 0 }} Labels,
|
||||||
|
{{ exportStats.logs || 0 }} Logs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Export</div>
|
||||||
|
<div class="setting-description">Alle Daten als JSON herunterladen</div>
|
||||||
|
</div>
|
||||||
|
<button class="export-btn" @click="exportData" :disabled="exportLoading">
|
||||||
|
<i :class="exportLoading ? 'pi pi-spin pi-spinner' : 'pi pi-download'" style="margin-right: 0.4rem;"></i>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Import</div>
|
||||||
|
<div class="setting-description">JSON-Datei importieren (Merge-Modus)</div>
|
||||||
|
</div>
|
||||||
|
<label class="import-btn" :class="{ disabled: importLoading }">
|
||||||
|
<i :class="importLoading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" style="margin-right: 0.4rem;"></i>
|
||||||
|
Import
|
||||||
|
<input type="file" accept=".json" @change="importData" hidden :disabled="importLoading" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="pi pi-bolt" style="margin-right: 0.5rem;"></i>WebSocket</h3>
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Status</div>
|
||||||
|
<div class="setting-value">
|
||||||
|
<span class="ws-dot" :class="{ online: wsStore.isConnected }">●</span>
|
||||||
|
{{ wsStore.isConnected ? 'Verbunden' : 'Getrennt' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Verbunden seit</div>
|
||||||
|
<div class="setting-value">{{ wsUptime }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Events empfangen</div>
|
||||||
|
<div class="setting-value">{{ wsStore.stats.eventsReceived }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Events gesendet</div>
|
||||||
|
<div class="setting-value">{{ wsStore.stats.eventsSent }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Reconnects</div>
|
||||||
|
<div class="setting-value">{{ Math.max(0, wsStore.stats.reconnects) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" v-if="wsStore.stats.lastEventType">
|
||||||
|
<div class="setting-label">Letztes Event</div>
|
||||||
|
<div class="setting-value mono">{{ wsStore.stats.lastEventType }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="subsection-title">Server</h4>
|
||||||
|
<div class="settings-card" v-if="serverStats">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Uptime</div>
|
||||||
|
<div class="setting-value">{{ serverUptime }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Verbindungen aktiv</div>
|
||||||
|
<div class="setting-value">{{ serverStats.currentConnections }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Verbindungen gesamt</div>
|
||||||
|
<div class="setting-value">{{ serverStats.totalConnections }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Broadcasts</div>
|
||||||
|
<div class="setting-value">{{ serverStats.totalBroadcasts }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Nachrichten gesendet</div>
|
||||||
|
<div class="setting-value">{{ serverStats.totalMessagesSent }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-label">Nachrichten empfangen</div>
|
||||||
|
<div class="setting-value">{{ serverStats.totalMessagesReceived }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="subsection-title" v-if="serverStats?.users?.length">Verbundene User</h4>
|
||||||
|
<div class="settings-card" v-if="serverStats?.users?.length">
|
||||||
|
<div class="setting-row" v-for="user in serverStats.users" :key="user.userId">
|
||||||
|
<div class="setting-label">
|
||||||
|
<span class="ws-dot online">●</span> {{ user.username }}
|
||||||
|
</div>
|
||||||
|
<div class="setting-value mono">↑{{ user.messagesSent }} ↓{{ user.messagesReceived }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authStore.user?.role === 'admin'" class="settings-section">
|
||||||
|
<h3>System</h3>
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Registrierung erlauben</div>
|
||||||
|
<div class="setting-description">Neue Benutzer können sich selbst registrieren</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="allowRegistration"
|
||||||
|
@change="toggleRegistration"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-value {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 52px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.3s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-slider {
|
||||||
|
background-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:disabled + .toggle-slider {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Select */
|
||||||
|
.theme-select {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-select:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-dot {
|
||||||
|
color: rgba(239, 68, 68, 0.8);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-dot.online {
|
||||||
|
color: rgba(34, 197, 94, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn, .import-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #a5b4fc;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn:hover, .import-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn:disabled, .import-btn.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
893
src/views/TaskDetailView.vue
Normal file
893
src/views/TaskDetailView.vue
Normal file
@@ -0,0 +1,893 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import AttachmentList from '../components/AttachmentList.vue'
|
||||||
|
import CommentList from '../components/CommentList.vue'
|
||||||
|
import TaskChangelog from '../components/TaskChangelog.vue'
|
||||||
|
import TaskCommits from '../components/TaskCommits.vue'
|
||||||
|
import SpeechToText from '../components/SpeechToText.vue'
|
||||||
|
|
||||||
|
interface Reminder {
|
||||||
|
enabled: boolean
|
||||||
|
datetime: string
|
||||||
|
interval: 'once' | 'daily' | 'hourly' | 'minutes'
|
||||||
|
intervalValue?: number
|
||||||
|
lastNotified?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
_id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
assignee?: string
|
||||||
|
project?: string
|
||||||
|
labels: string[]
|
||||||
|
reminder?: Reminder
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Label {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const taskId = computed(() => route.params.id as string)
|
||||||
|
const task = ref<Task | null>(null)
|
||||||
|
const editForm = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 'medium',
|
||||||
|
assignee: '',
|
||||||
|
project: '',
|
||||||
|
labels: [] as string[]
|
||||||
|
})
|
||||||
|
const agents = ref<Agent[]>([])
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const labels = ref<Label[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Reminder state
|
||||||
|
const showReminderForm = ref(false)
|
||||||
|
const reminderDate = ref('')
|
||||||
|
const reminderTime = ref('')
|
||||||
|
const reminderInterval = ref<'once' | 'daily' | 'hourly' | 'minutes'>('once')
|
||||||
|
const reminderMinutes = ref(30)
|
||||||
|
const savingReminder = ref(false)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'backlog', label: 'Backlog', color: '#64748b' },
|
||||||
|
{ value: 'todo', label: 'To Do', color: '#3b82f6' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress', color: '#f59e0b' },
|
||||||
|
{ value: 'review', label: 'Review', color: '#8b5cf6' },
|
||||||
|
{ value: 'done', label: 'Done', color: '#22c55e' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ value: 'low', label: 'Niedrig', color: '#64748b' },
|
||||||
|
{ value: 'medium', label: 'Mittel', color: '#3b82f6' },
|
||||||
|
{ value: 'high', label: 'Hoch', color: '#f59e0b' },
|
||||||
|
{ value: 'urgent', label: 'Dringend', color: '#ef4444' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const intervalOptions = [
|
||||||
|
{ value: 'once', label: 'Einmalig' },
|
||||||
|
{ value: 'daily', label: 'Täglich' },
|
||||||
|
{ value: 'hourly', label: 'Stündlich' },
|
||||||
|
{ value: 'minutes', label: 'Alle X Minuten' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const hasReminder = computed(() => task.value?.reminder?.enabled)
|
||||||
|
|
||||||
|
const reminderDisplay = computed(() => {
|
||||||
|
if (!task.value?.reminder) return null
|
||||||
|
const r = task.value.reminder
|
||||||
|
const dt = new Date(r.datetime)
|
||||||
|
const dateStr = dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const timeStr = dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
let intervalStr = ''
|
||||||
|
switch (r.interval) {
|
||||||
|
case 'once': intervalStr = 'Einmalig'; break
|
||||||
|
case 'daily': intervalStr = 'Täglich'; break
|
||||||
|
case 'hourly': intervalStr = 'Stündlich'; break
|
||||||
|
case 'minutes': intervalStr = `Alle ${r.intervalValue} Min`; break
|
||||||
|
}
|
||||||
|
|
||||||
|
return { date: dateStr, time: timeStr, interval: intervalStr, enabled: r.enabled }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Status-Reihenfolge für "Nächster Status" Button
|
||||||
|
const statusOrder = ['backlog', 'todo', 'in_progress', 'review', 'done']
|
||||||
|
const initialStatus = ref('')
|
||||||
|
|
||||||
|
function getNextStatus(current: string): string | null {
|
||||||
|
const idx = statusOrder.indexOf(current)
|
||||||
|
if (idx < 0 || idx >= statusOrder.length - 1) return null
|
||||||
|
return statusOrder[idx + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatusLabel = computed(() => {
|
||||||
|
const next = getNextStatus(editForm.value.status)
|
||||||
|
if (!next) return null
|
||||||
|
return statusOptions.find(o => o.value === next)?.label || next
|
||||||
|
})
|
||||||
|
|
||||||
|
async function advanceStatus() {
|
||||||
|
const next = getNextStatus(editForm.value.status)
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put(`/tasks/${taskId.value}`, { status: next })
|
||||||
|
editForm.value.status = next
|
||||||
|
if (task.value) task.value.status = next
|
||||||
|
toast.add({ severity: 'success', summary: 'Status geändert', detail: `→ ${statusOptions.find(o => o.value === next)?.label}`, life: 2000 })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToNextTask() {
|
||||||
|
if (!task.value?.project || !initialStatus.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ tasks: Task[] }>('/tasks')
|
||||||
|
const sameTasks = res.tasks
|
||||||
|
.filter(t => t.project === task.value!.project && t.status === initialStatus.value && t._id !== taskId.value)
|
||||||
|
.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
|
||||||
|
if (sameTasks.length > 0) {
|
||||||
|
router.push(`/tasks/${sameTasks[0]._id}`)
|
||||||
|
} else {
|
||||||
|
toast.add({ severity: 'info', summary: 'Kein weiterer Task', detail: 'Kein weiterer Task mit gleichem Status im Projekt', life: 3000 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Tasks konnten nicht geladen werden', life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTask() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [taskRes, agentsRes, projectsRes, labelsRes] = await Promise.all([
|
||||||
|
api.get<{ task: Task }>(`/tasks/${taskId.value}`),
|
||||||
|
api.get<{ agents: Agent[] }>('/agents'),
|
||||||
|
api.get<{ projects: Project[] }>('/tasks/projects/list'),
|
||||||
|
api.get<{ labels: Label[] }>('/labels')
|
||||||
|
])
|
||||||
|
task.value = taskRes.task
|
||||||
|
agents.value = agentsRes.agents
|
||||||
|
projects.value = projectsRes.projects
|
||||||
|
labels.value = labelsRes.labels
|
||||||
|
|
||||||
|
// Fill edit form
|
||||||
|
initialStatus.value = taskRes.task.status
|
||||||
|
editForm.value = {
|
||||||
|
title: taskRes.task.title,
|
||||||
|
description: taskRes.task.description || '',
|
||||||
|
status: taskRes.task.status,
|
||||||
|
priority: taskRes.task.priority,
|
||||||
|
assignee: taskRes.task.assignee || '',
|
||||||
|
project: taskRes.task.project || '',
|
||||||
|
labels: taskRes.task.labels || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill reminder form if exists
|
||||||
|
if (taskRes.task.reminder) {
|
||||||
|
const dt = new Date(taskRes.task.reminder.datetime)
|
||||||
|
reminderDate.value = dt.toISOString().split('T')[0]
|
||||||
|
reminderTime.value = dt.toTimeString().slice(0, 5)
|
||||||
|
reminderInterval.value = taskRes.task.reminder.interval
|
||||||
|
reminderMinutes.value = taskRes.task.reminder.intervalValue || 30
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Task nicht gefunden', life: 5000 })
|
||||||
|
router.push('/tasks')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTask() {
|
||||||
|
if (!editForm.value.title.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Titel ist erforderlich', life: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await api.put(`/tasks/${taskId.value}`, editForm.value)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Task aktualisiert', life: 3000 })
|
||||||
|
if (task.value) {
|
||||||
|
task.value = { ...task.value, ...editForm.value }
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReminder() {
|
||||||
|
if (!reminderDate.value || !reminderTime.value) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Hinweis', detail: 'Datum und Uhrzeit sind erforderlich', life: 3000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savingReminder.value = true
|
||||||
|
try {
|
||||||
|
const datetime = new Date(`${reminderDate.value}T${reminderTime.value}`)
|
||||||
|
|
||||||
|
await api.patch(`/tasks/${taskId.value}/reminder`, {
|
||||||
|
datetime: datetime.toISOString(),
|
||||||
|
interval: reminderInterval.value,
|
||||||
|
intervalValue: reminderInterval.value === 'minutes' ? reminderMinutes.value : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update local task
|
||||||
|
if (task.value) {
|
||||||
|
task.value.reminder = {
|
||||||
|
enabled: true,
|
||||||
|
datetime: datetime.toISOString(),
|
||||||
|
interval: reminderInterval.value,
|
||||||
|
intervalValue: reminderMinutes.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showReminderForm.value = false
|
||||||
|
toast.add({ severity: 'success', summary: 'Reminder gesetzt', detail: 'Du wirst erinnert', life: 3000 })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
} finally {
|
||||||
|
savingReminder.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteReminder() {
|
||||||
|
if (!confirm('Reminder löschen?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/tasks/${taskId.value}/reminder`)
|
||||||
|
if (task.value) {
|
||||||
|
task.value.reminder = undefined
|
||||||
|
}
|
||||||
|
toast.add({ severity: 'success', summary: 'Entfernt', detail: 'Reminder gelöscht', life: 3000 })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.message, life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReminderForm() {
|
||||||
|
// Set default values
|
||||||
|
if (!reminderDate.value) {
|
||||||
|
const tomorrow = new Date()
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
reminderDate.value = tomorrow.toISOString().split('T')[0]
|
||||||
|
reminderTime.value = '09:00'
|
||||||
|
}
|
||||||
|
showReminderForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
return statusOptions.find(s => s.value === status)?.color || '#64748b'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityColor(priority: string) {
|
||||||
|
return priorityOptions.find(p => p.value === priority)?.color || '#64748b'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTask)
|
||||||
|
|
||||||
|
// Wenn sich die Route ändert (z.B. "Nächster Task"), Task neu laden
|
||||||
|
watch(() => route.params.id, (newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
loadTask()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="back-link">
|
||||||
|
<router-link to="/tasks">← Zurück zu Tasks</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="task" class="task-detail-layout">
|
||||||
|
<!-- LEFT: Edit Form -->
|
||||||
|
<div class="edit-panel">
|
||||||
|
<h2><i class="pi pi-pencil"></i> Task bearbeiten</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Titel</label>
|
||||||
|
<div class="input-with-stt">
|
||||||
|
<input v-model="editForm.title" type="text" placeholder="Task-Titel" />
|
||||||
|
<SpeechToText @transcribed="(t: string) => editForm.title += (editForm.title ? ' ' : '') + t" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<div class="input-with-stt">
|
||||||
|
<textarea v-model="editForm.description" rows="4" placeholder="Beschreibung (optional)"></textarea>
|
||||||
|
<SpeechToText @transcribed="(t: string) => editForm.description += (editForm.description ? ' ' : '') + t" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Status</label>
|
||||||
|
<select v-model="editForm.status">
|
||||||
|
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="status-indicator" :style="{ background: getStatusColor(editForm.status) }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Priorität</label>
|
||||||
|
<select v-model="editForm.priority">
|
||||||
|
<option v-for="opt in priorityOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="status-indicator" :style="{ background: getPriorityColor(editForm.priority) }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Agent</label>
|
||||||
|
<select v-model="editForm.assignee">
|
||||||
|
<option value="">Nicht zugewiesen</option>
|
||||||
|
<option v-for="agent in agents" :key="agent._id" :value="agent._id">
|
||||||
|
{{ agent.emoji }} {{ agent.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Projekt</label>
|
||||||
|
<select v-model="editForm.project">
|
||||||
|
<option value="">Kein Projekt</option>
|
||||||
|
<option v-for="project in projects" :key="project._id" :value="project._id">
|
||||||
|
{{ project.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Labels</label>
|
||||||
|
<select v-model="editForm.labels" multiple class="multi-select">
|
||||||
|
<option v-for="label in labels" :key="label._id" :value="label._id">
|
||||||
|
{{ label.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="labels-preview">
|
||||||
|
<span v-for="labelId in editForm.labels" :key="labelId"
|
||||||
|
class="label-badge"
|
||||||
|
:style="{ background: labels.find(l => l._id === labelId)?.color }">
|
||||||
|
{{ labels.find(l => l._id === labelId)?.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="saveTask" class="save-btn" :disabled="saving">
|
||||||
|
<i class="pi" :class="saving ? 'pi-spin pi-spinner' : 'pi-check'"></i>
|
||||||
|
{{ saving ? 'Speichert...' : 'Änderungen speichern' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button
|
||||||
|
v-if="nextStatusLabel"
|
||||||
|
@click="advanceStatus"
|
||||||
|
class="quick-btn next-status"
|
||||||
|
>
|
||||||
|
<i class="pi pi-arrow-right"></i>
|
||||||
|
→ {{ nextStatusLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="task?.project"
|
||||||
|
@click="goToNextTask"
|
||||||
|
class="quick-btn next-task"
|
||||||
|
>
|
||||||
|
<i class="pi pi-forward"></i>
|
||||||
|
Nächster Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reminder Section -->
|
||||||
|
<div class="reminder-section">
|
||||||
|
<h3><i class="pi pi-bell"></i> Reminder</h3>
|
||||||
|
|
||||||
|
<!-- Current Reminder Display -->
|
||||||
|
<div v-if="hasReminder && reminderDisplay" class="reminder-display">
|
||||||
|
<div class="reminder-info">
|
||||||
|
<span class="reminder-datetime">
|
||||||
|
<i class="pi pi-calendar"></i> {{ reminderDisplay.date }}
|
||||||
|
<i class="pi pi-clock"></i> {{ reminderDisplay.time }}
|
||||||
|
</span>
|
||||||
|
<span class="reminder-interval">{{ reminderDisplay.interval }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reminder-actions">
|
||||||
|
<button @click="openReminderForm" class="icon-btn" title="Bearbeiten">
|
||||||
|
<i class="pi pi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button @click="deleteReminder" class="icon-btn delete" title="Löschen">
|
||||||
|
<i class="pi pi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Reminder -->
|
||||||
|
<div v-else-if="!showReminderForm" class="no-reminder">
|
||||||
|
<button @click="openReminderForm" class="add-reminder-btn">
|
||||||
|
<i class="pi pi-plus"></i> Reminder hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reminder Form -->
|
||||||
|
<div v-if="showReminderForm" class="reminder-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Datum</label>
|
||||||
|
<input v-model="reminderDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Uhrzeit</label>
|
||||||
|
<input v-model="reminderTime" type="time" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Wiederholung</label>
|
||||||
|
<select v-model="reminderInterval">
|
||||||
|
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reminderInterval === 'minutes'" class="form-group">
|
||||||
|
<label>Alle X Minuten</label>
|
||||||
|
<input v-model.number="reminderMinutes" type="number" min="1" max="1440" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reminder-form-actions">
|
||||||
|
<button @click="showReminderForm = false" class="cancel-btn">Abbrechen</button>
|
||||||
|
<button @click="saveReminder" class="save-reminder-btn" :disabled="savingReminder">
|
||||||
|
<i class="pi" :class="savingReminder ? 'pi-spin pi-spinner' : 'pi-bell'"></i>
|
||||||
|
{{ savingReminder ? 'Speichert...' : 'Reminder setzen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GitLab Commits Section -->
|
||||||
|
<TaskCommits
|
||||||
|
v-if="editForm.project"
|
||||||
|
:taskId="taskId"
|
||||||
|
:projectId="editForm.project"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Attachments Section (Universal Component) -->
|
||||||
|
<div class="attachments-section">
|
||||||
|
<AttachmentList parentType="task" :parentId="taskId" :enablePaste="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Comments + Changelog -->
|
||||||
|
<div class="comments-panel">
|
||||||
|
<CommentList parentType="task" :parentId="taskId" />
|
||||||
|
<TaskChangelog
|
||||||
|
:taskId="taskId"
|
||||||
|
:agents="agents"
|
||||||
|
:labels="labels"
|
||||||
|
:projects="projects"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link a {
|
||||||
|
color: #818cf8;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-Column Layout */
|
||||||
|
.task-detail-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
min-height: calc(100vh - 200px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.task-detail-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Panel: Edit Form */
|
||||||
|
.edit-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-panel h2 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-stt {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-stt input,
|
||||||
|
.input-with-stt textarea {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 38px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn.next-status {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn.next-status:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn.next-task {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn.next-task:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reminder Section */
|
||||||
|
.reminder-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-section h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-datetime {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-datetime i {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-interval {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-reminder {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-reminder-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px dashed rgba(99, 102, 241, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #818cf8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-reminder-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-form {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-reminder-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-reminder-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attachments */
|
||||||
|
.attachments-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Panel: Comments */
|
||||||
|
.comments-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon buttons */
|
||||||
|
.icon-btn {
|
||||||
|
padding: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.delete:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.multi-select {
|
||||||
|
min-height: 100px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select option {
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labels-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1356
src/views/TasksView.vue
Normal file
1356
src/views/TasksView.vue
Normal file
File diff suppressed because it is too large
Load Diff
560
src/views/TokenAnalyticsView.vue
Normal file
560
src/views/TokenAnalyticsView.vue
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { Line, Bar, Doughnut } from 'vue-chartjs'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
ArcElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale, LinearScale, PointElement, LineElement,
|
||||||
|
BarElement, ArcElement, Title, Tooltip, Legend, Filler
|
||||||
|
)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
emoji: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeseriesEntry {
|
||||||
|
_id: string | { date: string; agentId: string; agentName: string }
|
||||||
|
totalTokens: number
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
totalCost: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsEntry {
|
||||||
|
_id: string
|
||||||
|
totalTokens: number
|
||||||
|
totalCost: number
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
count: number
|
||||||
|
agentName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Totals {
|
||||||
|
totalTokens: number
|
||||||
|
totalCost: number
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = ref<Agent[]>([])
|
||||||
|
const selectedAgent = ref('')
|
||||||
|
const selectedDays = ref(30)
|
||||||
|
const selectedPeriod = ref('month')
|
||||||
|
const timeseriesData = ref<TimeseriesEntry[]>([])
|
||||||
|
const statsData = ref<StatsEntry[]>([])
|
||||||
|
const totals = ref<Totals>({ totalTokens: 0, totalCost: 0, inputTokens: 0, outputTokens: 0, count: 0 })
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const daysOptions = [
|
||||||
|
{ label: '7 Tage', value: 7 },
|
||||||
|
{ label: '14 Tage', value: 14 },
|
||||||
|
{ label: '30 Tage', value: 30 },
|
||||||
|
{ label: '90 Tage', value: 90 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
{ label: 'Heute', value: 'day' },
|
||||||
|
{ label: 'Woche', value: 'week' },
|
||||||
|
{ label: 'Monat', value: 'month' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Chart: Token-Verlauf pro Tag (Line)
|
||||||
|
const lineChartData = computed(() => {
|
||||||
|
const labels = timeseriesData.value.map(d => {
|
||||||
|
const dateStr = typeof d._id === 'string' ? d._id : d._id.date
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Input Tokens',
|
||||||
|
data: timeseriesData.value.map(d => d.inputTokens),
|
||||||
|
borderColor: '#818cf8',
|
||||||
|
backgroundColor: 'rgba(129, 140, 248, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Output Tokens',
|
||||||
|
data: timeseriesData.value.map(d => d.outputTokens),
|
||||||
|
borderColor: '#34d399',
|
||||||
|
backgroundColor: 'rgba(52, 211, 153, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chart: Kosten pro Tag (Bar)
|
||||||
|
const costChartData = computed(() => {
|
||||||
|
const labels = timeseriesData.value.map(d => {
|
||||||
|
const dateStr = typeof d._id === 'string' ? d._id : d._id.date
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Kosten ($)',
|
||||||
|
data: timeseriesData.value.map(d => +(d.totalCost || 0).toFixed(4)),
|
||||||
|
backgroundColor: 'rgba(251, 191, 36, 0.6)',
|
||||||
|
borderColor: '#fbbf24',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Chart: Verteilung pro Agent (Doughnut)
|
||||||
|
const agentChartData = computed(() => {
|
||||||
|
const colors = ['#818cf8', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#38bdf8', '#fb923c', '#4ade80']
|
||||||
|
return {
|
||||||
|
labels: statsData.value.map(s => s.agentName || String(s._id).substring(0, 8)),
|
||||||
|
datasets: [{
|
||||||
|
data: statsData.value.map(s => s.totalTokens),
|
||||||
|
backgroundColor: statsData.value.map((_, i) => colors[i % colors.length]),
|
||||||
|
borderWidth: 0,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: { color: 'rgba(255,255,255,0.7)', font: { size: 12 } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 11 } },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 11 } },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.05)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doughnutOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right' as const,
|
||||||
|
labels: { color: 'rgba(255,255,255,0.7)', font: { size: 12 }, padding: 12 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||||
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ agents: Agent[] }>('/agents')
|
||||||
|
agents.value = res.agents || []
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const agentParam = selectedAgent.value ? `&agentId=${selectedAgent.value}` : ''
|
||||||
|
|
||||||
|
const [tsRes, statsRes] = await Promise.all([
|
||||||
|
api.get<{ data: TimeseriesEntry[] }>(`/tokens/timeseries?days=${selectedDays.value}${agentParam}`),
|
||||||
|
api.get<{ stats: StatsEntry[]; totals: Totals }>(`/tokens/stats?period=${selectedPeriod.value}${agentParam}`)
|
||||||
|
])
|
||||||
|
|
||||||
|
timeseriesData.value = tsRes.data || []
|
||||||
|
statsData.value = statsRes.stats || []
|
||||||
|
totals.value = statsRes.totals || { totalTokens: 0, totalCost: 0, inputTokens: 0, outputTokens: 0, count: 0 }
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Analytics konnten nicht geladen werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAgents()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="analytics-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="pi pi-chart-line" style="margin-right: 0.5rem;"></i>Token Analytics</h1>
|
||||||
|
<button class="refresh-btn" @click="loadData" :disabled="loading">
|
||||||
|
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Agent</label>
|
||||||
|
<select v-model="selectedAgent" @change="loadData">
|
||||||
|
<option value="">Alle Agenten</option>
|
||||||
|
<option v-for="a in agents" :key="a._id" :value="a._id">{{ a.emoji }} {{ a.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Zeitraum (Chart)</label>
|
||||||
|
<select v-model="selectedDays" @change="loadData">
|
||||||
|
<option v-for="o in daysOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Statistik</label>
|
||||||
|
<select v-model="selectedPeriod" @change="loadData">
|
||||||
|
<option v-for="o in periodOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<div class="kpi-row">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-icon"><i class="pi pi-bolt"></i></div>
|
||||||
|
<div class="kpi-content">
|
||||||
|
<span class="kpi-value">{{ formatTokens(totals.totalTokens) }}</span>
|
||||||
|
<span class="kpi-label">Tokens gesamt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-icon input"><i class="pi pi-arrow-right"></i></div>
|
||||||
|
<div class="kpi-content">
|
||||||
|
<span class="kpi-value">{{ formatTokens(totals.inputTokens) }}</span>
|
||||||
|
<span class="kpi-label">Input Tokens</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-icon output"><i class="pi pi-arrow-left"></i></div>
|
||||||
|
<div class="kpi-content">
|
||||||
|
<span class="kpi-value">{{ formatTokens(totals.outputTokens) }}</span>
|
||||||
|
<span class="kpi-label">Output Tokens</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-icon cost"><i class="pi pi-dollar"></i></div>
|
||||||
|
<div class="kpi-content">
|
||||||
|
<span class="kpi-value">${{ (totals.totalCost || 0).toFixed(2) }}</span>
|
||||||
|
<span class="kpi-label">Kosten</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-icon requests"><i class="pi pi-send"></i></div>
|
||||||
|
<div class="kpi-content">
|
||||||
|
<span class="kpi-value">{{ totals.count }}</span>
|
||||||
|
<span class="kpi-label">Anfragen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<i class="pi pi-spin pi-spinner" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="charts-grid">
|
||||||
|
<!-- Token Verlauf -->
|
||||||
|
<div class="chart-card wide">
|
||||||
|
<h3>Token-Verlauf</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<Line v-if="timeseriesData.length" :data="lineChartData" :options="chartOptions" />
|
||||||
|
<div v-else class="empty-chart">Keine Daten im gewählten Zeitraum</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kosten pro Tag -->
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Kosten pro Tag</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<Bar v-if="timeseriesData.length" :data="costChartData" :options="chartOptions" />
|
||||||
|
<div v-else class="empty-chart">Keine Daten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verteilung pro Agent -->
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Verteilung pro Agent</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<Doughnut v-if="statsData.length" :data="agentChartData" :options="doughnutOptions" />
|
||||||
|
<div v-else class="empty-chart">Keine Daten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent-Tabelle -->
|
||||||
|
<div v-if="statsData.length" class="stats-table-card">
|
||||||
|
<h3>Nutzung pro Agent</h3>
|
||||||
|
<table class="stats-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Agent</th>
|
||||||
|
<th>Anfragen</th>
|
||||||
|
<th>Input</th>
|
||||||
|
<th>Output</th>
|
||||||
|
<th>Gesamt</th>
|
||||||
|
<th>Kosten</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="s in statsData" :key="String(s._id)">
|
||||||
|
<td>{{ s.agentName || String(s._id).substring(0, 8) }}</td>
|
||||||
|
<td>{{ s.count }}</td>
|
||||||
|
<td>{{ formatTokens(s.inputTokens) }}</td>
|
||||||
|
<td>{{ formatTokens(s.outputTokens) }}</td>
|
||||||
|
<td>{{ formatTokens(s.totalTokens) }}</td>
|
||||||
|
<td>${{ (s.totalCost || 0).toFixed(4) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.analytics-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||||
|
|
||||||
|
/* Filter */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI Cards */
|
||||||
|
.kpi-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
background: rgba(129, 140, 248, 0.15);
|
||||||
|
color: #818cf8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.kpi-icon.input { background: rgba(129, 140, 248, 0.15); color: #818cf8; }
|
||||||
|
.kpi-icon.output { background: rgba(52, 211, 153, 0.15); color: #34d399; }
|
||||||
|
.kpi-icon.cost { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
|
||||||
|
.kpi-icon.requests { background: rgba(248, 113, 113, 0.15); color: #f87171; }
|
||||||
|
|
||||||
|
.kpi-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
.charts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 280px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Table */
|
||||||
|
.stats-table-card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table-card h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table td {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.charts-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.chart-card.wide {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.kpi-row {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
373
src/views/WorkspaceView.vue
Normal file
373
src/views/WorkspaceView.vue
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { api } from '../api'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import Textarea from 'primevue/textarea'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
import Toolbar from 'primevue/toolbar'
|
||||||
|
|
||||||
|
interface WorkspaceFile {
|
||||||
|
_id: string
|
||||||
|
agentId: string
|
||||||
|
agentName: string
|
||||||
|
filename: string
|
||||||
|
content?: string
|
||||||
|
updatedBy: string
|
||||||
|
updatedByName: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
_id: string
|
||||||
|
filename: string
|
||||||
|
content: string
|
||||||
|
version: number
|
||||||
|
changedBy: string
|
||||||
|
changedByName: string
|
||||||
|
changeType: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const files = ref<WorkspaceFile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const editDialogVisible = ref(false)
|
||||||
|
const historyDialogVisible = ref(false)
|
||||||
|
const selectedFile = ref<WorkspaceFile | null>(null)
|
||||||
|
const editContent = ref('')
|
||||||
|
const history = ref<HistoryEntry[]>([])
|
||||||
|
const historyLoading = ref(false)
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.get<{ files: WorkspaceFile[] }>('/workspace/files')
|
||||||
|
files.value = result.files
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Dateien konnten nicht geladen werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFile = async (file: WorkspaceFile) => {
|
||||||
|
try {
|
||||||
|
const result = await api.get<{ file: WorkspaceFile }>(`/workspace/files/${file._id}`)
|
||||||
|
selectedFile.value = result.file
|
||||||
|
editContent.value = result.file.content || ''
|
||||||
|
editDialogVisible.value = true
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Datei konnte nicht geladen werden', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveFile = async () => {
|
||||||
|
if (!selectedFile.value) return
|
||||||
|
try {
|
||||||
|
await api.put('/workspace/files', {
|
||||||
|
agentId: selectedFile.value.agentId,
|
||||||
|
agentName: selectedFile.value.agentName,
|
||||||
|
filename: selectedFile.value.filename,
|
||||||
|
content: editContent.value,
|
||||||
|
})
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: `${selectedFile.value.filename} aktualisiert`, life: 3000 })
|
||||||
|
editDialogVisible.value = false
|
||||||
|
await loadFiles()
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'Speichern fehlgeschlagen', life: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openHistory = async (file: WorkspaceFile) => {
|
||||||
|
historyLoading.value = true
|
||||||
|
historyDialogVisible.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.get<{ history: HistoryEntry[] }>(`/workspace/files/${file._id}/history`)
|
||||||
|
history.value = result.history
|
||||||
|
} catch {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: 'History konnte nicht geladen werden', life: 3000 })
|
||||||
|
} finally {
|
||||||
|
historyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = (content: string | undefined) => {
|
||||||
|
if (!content) return '–'
|
||||||
|
const bytes = new TextEncoder().encode(content).length
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileIcon = (filename: string) => {
|
||||||
|
if (filename === 'SOUL.md') return 'pi pi-heart'
|
||||||
|
if (filename === 'MEMORY.md') return 'pi pi-database'
|
||||||
|
if (filename === 'IDENTITY.md') return 'pi pi-id-card'
|
||||||
|
if (filename === 'USER.md') return 'pi pi-user'
|
||||||
|
if (filename === 'TOOLS.md') return 'pi pi-wrench'
|
||||||
|
if (filename === 'WORKFLOW.md') return 'pi pi-list'
|
||||||
|
if (filename === 'CHECKLIST.md') return 'pi pi-check-square'
|
||||||
|
if (filename === 'HEARTBEAT.md') return 'pi pi-heart-fill'
|
||||||
|
if (filename === 'AGENTS.md') return 'pi pi-cog'
|
||||||
|
return 'pi pi-file'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadFiles)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="workspace-view">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2><i class="pi pi-folder-open"></i> Agent Workspace</h2>
|
||||||
|
<Button
|
||||||
|
label="Aktualisieren"
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
:loading="loading"
|
||||||
|
@click="loadFiles"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<DataTable
|
||||||
|
:value="files"
|
||||||
|
:loading="loading"
|
||||||
|
class="p-datatable-sm"
|
||||||
|
sortField="filename"
|
||||||
|
:sortOrder="1"
|
||||||
|
>
|
||||||
|
<Column field="filename" header="Datei" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i :class="fileIcon(data.filename)" />
|
||||||
|
<span class="font-mono font-semibold">{{ data.filename }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="agentName" header="Agent" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.agentName" severity="info" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="version" header="Version" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="`v${data.version}`" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="updatedByName" header="Zuletzt geändert von" sortable />
|
||||||
|
<Column field="updatedAt" header="Aktualisiert" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDate(data.updatedAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Aktionen" :exportable="false" style="min-width: 12rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
severity="info"
|
||||||
|
size="small"
|
||||||
|
rounded
|
||||||
|
text
|
||||||
|
@click="openFile(data)"
|
||||||
|
v-tooltip="'Bearbeiten'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-history"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
rounded
|
||||||
|
text
|
||||||
|
@click="openHistory(data)"
|
||||||
|
v-tooltip="'History'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="editDialogVisible"
|
||||||
|
:header="selectedFile?.filename || 'Datei bearbeiten'"
|
||||||
|
modal
|
||||||
|
:style="{ width: '80vw', maxWidth: '1200px' }"
|
||||||
|
class="dark-dialog"
|
||||||
|
>
|
||||||
|
<div class="file-meta">
|
||||||
|
<span><i class="pi pi-user"></i> {{ selectedFile?.updatedByName }}</span>
|
||||||
|
<span><i class="pi pi-clock"></i> {{ selectedFile ? formatDate(selectedFile.updatedAt) : '' }}</span>
|
||||||
|
<Tag :value="`v${selectedFile?.version}`" severity="secondary" />
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
v-model="editContent"
|
||||||
|
rows="25"
|
||||||
|
class="file-editor"
|
||||||
|
autoResize
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<button class="dialog-btn" @click="editDialogVisible = false">Abbrechen</button>
|
||||||
|
<button class="dialog-btn primary" @click="saveFile">Speichern</button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- History Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="historyDialogVisible"
|
||||||
|
header="Änderungsverlauf"
|
||||||
|
modal
|
||||||
|
:style="{ width: '60vw', maxWidth: '900px' }"
|
||||||
|
class="dark-dialog"
|
||||||
|
>
|
||||||
|
<DataTable :value="history" :loading="historyLoading" class="p-datatable-sm">
|
||||||
|
<Column field="version" header="Version">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="`v${data.version}`" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="changeType" header="Typ">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
:value="data.changeType"
|
||||||
|
:severity="data.changeType === 'create' ? 'success' : data.changeType === 'restore' ? 'warn' : 'info'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="changedByName" header="Geändert von" />
|
||||||
|
<Column field="createdAt" header="Datum">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDate(data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workspace-view {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta i {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-editor {
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 400px;
|
||||||
|
background: #2a3144 !important;
|
||||||
|
color: #d4d4d4 !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem !important;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono {
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-datatable) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-datatable .p-datatable-thead > tr > th) {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #aab;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-datatable .p-datatable-tbody > tr) {
|
||||||
|
background: transparent;
|
||||||
|
color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-datatable .p-datatable-tbody > tr > td) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.p-datatable .p-datatable-tbody > tr:hover) {
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.primary {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border-color: rgba(99, 102, 241, 0.3);
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn.primary:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
vite.config.ts
Normal file
9
vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user