Initial commit: AMS Frontend - Vue 3 + PrimeVue
This commit is contained in:
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 -->
|
||||
Reference in New Issue
Block a user