kin: KIN-125-frontend_dev
This commit is contained in:
parent
49ea6542b8
commit
24fd8ca72d
3 changed files with 217 additions and 30 deletions
|
|
@ -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">×</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">×</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>
|
||||
|
|
|
|||
|
|
@ -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": "▲ Скрыть лог",
|
||||
|
|
|
|||
|
|
@ -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": "▲ Скрыть лог",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue