kin: KIN-125-frontend_dev

This commit is contained in:
Gros Frumos 2026-03-18 15:32:44 +02:00
parent 49ea6542b8
commit 24fd8ca72d
3 changed files with 217 additions and 30 deletions

View file

@ -1,16 +1,20 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type EscalationNotification } from '../api'
import { useRouter } from 'vue-router'
import { api, type EscalationNotification, type Task } from '../api'
const { t, locale } = useI18n()
const router = useRouter()
const STORAGE_KEY = 'kin_dismissed_escalations'
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
const COMPLETED_STORAGE_KEY = 'kin_dismissed_completed'
const notifications = ref<EscalationNotification[]>([])
const showPanel = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
let completedPollTimer: ReturnType<typeof setInterval> | null = null
function loadDismissed(): Set<string> {
try {
@ -115,13 +119,99 @@ function formatTime(iso: string): string {
}
}
// --- Completed tasks ---
interface CompletedTaskItem extends Task {
project_name: string
}
const completedTasks = ref<CompletedTaskItem[]>([])
function loadDismissedCompleted(): Set<string> {
try {
const raw = localStorage.getItem(COMPLETED_STORAGE_KEY)
return new Set(raw ? JSON.parse(raw) : [])
} catch {
return new Set()
}
}
function saveDismissedCompleted(ids: Set<string>) {
localStorage.setItem(COMPLETED_STORAGE_KEY, JSON.stringify([...ids]))
}
const dismissedCompleted = ref<Set<string>>(loadDismissedCompleted())
const visibleCompleted = computed(() =>
completedTasks.value.filter(t => !dismissedCompleted.value.has(t.id))
)
async function loadCompletedTasks() {
try {
const projects = await api.projects()
const withCompleted = projects.filter(p => p.done_tasks > 0)
if (withCompleted.length === 0) {
completedTasks.value = []
return
}
const details = await Promise.all(withCompleted.map(p => api.project(p.id)))
const results: CompletedTaskItem[] = []
for (let i = 0; i < details.length; i++) {
const projectName = withCompleted[i].name
for (const task of details[i].tasks) {
if (task.status === 'completed') {
results.push({ ...task, project_name: projectName })
}
}
}
results.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
completedTasks.value = results.slice(0, 20)
} catch {
// silent
}
}
function doneCompleted(taskId: string) {
dismissedCompleted.value = new Set([...dismissedCompleted.value, taskId])
saveDismissedCompleted(dismissedCompleted.value)
if (visibleCompleted.value.length === 0 && visible.value.length === 0) showPanel.value = false
}
const revisingTaskId = ref<string | null>(null)
const reviseComment = ref('')
function startRevise(taskId: string) {
revisingTaskId.value = taskId
reviseComment.value = ''
}
async function confirmRevise(taskId: string) {
try {
await api.reviseTask(taskId, reviseComment.value || t('escalation.revise_default_comment'))
doneCompleted(taskId)
} catch {
// silent
} finally {
revisingTaskId.value = null
reviseComment.value = ''
}
}
function cancelRevise() {
revisingTaskId.value = null
reviseComment.value = ''
}
onMounted(async () => {
await load()
pollTimer = setInterval(load, 10000)
await loadCompletedTasks()
completedPollTimer = setInterval(loadCompletedTasks, 30000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
if (completedPollTimer) clearInterval(completedPollTimer)
// KIN-099: clear watchdog toast auto-dismiss timers to prevent memory leaks
for (const toast of watchdogToasts.value) {
if (toast.timerId) clearTimeout(toast.timerId)
@ -148,8 +238,8 @@ onUnmounted(() => {
</div>
</div>
<div class="relative">
<!-- Badge-кнопка видна только при наличии активных эскалаций -->
<div class="relative flex items-center gap-1">
<!-- Badge-кнопка эскалаций -->
<button
v-if="visible.length > 0"
@click="showPanel = !showPanel"
@ -160,23 +250,38 @@ onUnmounted(() => {
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
</button>
<!-- Панель уведомлений -->
<!-- Badge-кнопка завершённых задач -->
<button
v-if="visibleCompleted.length > 0"
@click="showPanel = !showPanel"
class="relative flex items-center gap-1.5 px-2.5 py-1 text-xs bg-green-900/40 text-green-400 border border-green-800 rounded hover:bg-green-900/60 transition-colors"
>
<span class="inline-block w-1.5 h-1.5 bg-green-500 rounded-full"></span>
{{ t('escalation.completed_tasks') }}
<span class="ml-0.5 font-bold">{{ visibleCompleted.length }}</span>
</button>
<!-- Объединённая панель уведомлений -->
<div
v-if="showPanel && visible.length > 0"
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-red-900/60 rounded-lg shadow-2xl z-50"
v-if="showPanel && (visible.length > 0 || visibleCompleted.length > 0)"
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl z-50"
>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
<span class="text-xs font-semibold text-gray-400">Kin</span>
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">&times;</button>
</div>
<!-- Секция эскалаций -->
<div v-if="visible.length > 0">
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800/60">
<span class="text-xs font-semibold text-red-400">{{ t('escalation.escalations_panel_title') }}</span>
<div class="flex items-center gap-2">
<button
@click="dismissAll"
class="text-xs text-gray-500 hover:text-gray-300"
>{{ t('escalation.dismiss_all') }}</button>
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">&times;</button>
</div>
</div>
<div class="max-h-80 overflow-y-auto divide-y divide-gray-800">
<div class="max-h-60 overflow-y-auto divide-y divide-gray-800">
<div
v-for="n in visible"
:key="n.task_id"
@ -202,6 +307,72 @@ onUnmounted(() => {
</div>
</div>
<!-- Разделитель между секциями -->
<div v-if="visible.length > 0 && visibleCompleted.length > 0" class="border-t border-gray-700"></div>
<!-- Секция завершённых задач -->
<div v-if="visibleCompleted.length > 0">
<div class="px-4 py-2 border-b border-gray-800/60">
<span class="text-xs font-semibold text-green-400">{{ t('escalation.completed_panel_title') }}</span>
</div>
<div class="max-h-60 overflow-y-auto divide-y divide-gray-800">
<div
v-for="task in visibleCompleted"
:key="task.id"
class="px-4 py-3 cursor-pointer hover:bg-gray-800/40 transition-colors"
@click="router.push(`/task/${task.id}`); showPanel = false"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-xs font-mono text-green-400 shrink-0">{{ task.id }}</span>
<span class="text-xs text-gray-500">·</span>
<span class="text-xs text-gray-500 truncate">{{ task.project_name }}</span>
</div>
<p class="text-xs text-gray-200 leading-snug break-words">{{ task.title }}</p>
<p class="text-xs text-gray-600 mt-1">{{ formatTime(task.updated_at) }}</p>
<!-- Inline revise form -->
<div
v-if="revisingTaskId === task.id"
class="mt-2 flex gap-1"
@click.stop
>
<input
v-model="reviseComment"
type="text"
:placeholder="t('escalation.revise_comment_placeholder')"
class="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-400"
@keyup.enter="confirmRevise(task.id)"
@keyup.escape="cancelRevise"
/>
<button
@click="confirmRevise(task.id)"
class="px-2 py-1 text-xs bg-green-900/50 text-green-400 border border-green-700 rounded hover:bg-green-900 transition-colors"
>{{ t('escalation.revise_send') }}</button>
<button
@click="cancelRevise"
class="px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 transition-colors"
>{{ t('escalation.revise_cancel') }}</button>
</div>
</div>
<div class="flex flex-col gap-1 shrink-0" @click.stop>
<button
v-if="revisingTaskId !== task.id"
@click="startRevise(task.id)"
class="px-2 py-1 text-xs bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900/60 transition-colors"
>{{ t('escalation.revise') }}</button>
<button
@click="doneCompleted(task.id)"
class="px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200 transition-colors"
>{{ t('escalation.done') }}</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Overlay для закрытия панели -->
<div v-if="showPanel" class="fixed inset-0 z-40" @click="showPanel = false"></div>
</div>

View file

@ -224,7 +224,15 @@
"escalations": "Escalations",
"escalations_panel_title": "Escalations — action required",
"dismiss_all": "Dismiss all",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"completed_tasks": "Completed",
"completed_panel_title": "Completed tasks — review",
"done": "Done",
"revise": "Revise",
"revise_comment_placeholder": "Revision comment...",
"revise_send": "Send",
"revise_cancel": "Cancel",
"revise_default_comment": "Sent for revision"
},
"liveConsole": {
"hide_log": "▲ Скрыть лог",

View file

@ -224,7 +224,15 @@
"escalations": "Эскалации",
"escalations_panel_title": "Эскалации — требуется решение",
"dismiss_all": "Принять все",
"dismiss": "Принято"
"dismiss": "Принято",
"completed_tasks": "Завершено",
"completed_panel_title": "Завершённые задачи — к проверке",
"done": "Готово",
"revise": "Доработать",
"revise_comment_placeholder": "Комментарий к доработке...",
"revise_send": "Отправить",
"revise_cancel": "Отмена",
"revise_default_comment": "Отправлено на доработку"
},
"liveConsole": {
"hide_log": "▲ Скрыть лог",