<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Project, type TaskEvent } from '../api'
import { useRealtimeRefresh } from '../realtime'
import { formatDateTime, statusLabel } from '../taskui'
import Pagination from '../components/Pagination.vue'
// Журнал действий (ТЗ 3.20): кто и что делал с задачами. Уникален тем, что
// показывает актора — у остальных разделов есть только результат.
// Единственный серверно-пагинированный список: журнал растёт быстро, целиком
// его не отдают (потолок per_page на бэке — 100).
const { t } = useI18n()
const PER_PAGE = 50
const events = ref<TaskEvent[]>([])
const projects = ref<Project[]>([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const error = ref('')
// Виды записей — из tasklog.py; порядок: как читается жизнь задачи
const KINDS = [
'created',
'eligible_changed',
'claimed',
'released',
'status_changed',
'completed',
'accepted',
'rejected',
'deleted',
] as const
const filters = ref({
kind: '',
actor: '' as '' | 'user' | 'agent',
project: '' as string | number,
})
const kindOptions = computed(() => [
{ value: '', label: t('journal.filterKindAll') },
...KINDS.map((k) => ({ value: k, label: t(`journal.kind.${k}`) })),
])
const actorOptions = computed(() => [
{ value: '', label: t('journal.filterActorAll') },
{ value: 'user', label: t('journal.actorUser') },
{ value: 'agent', label: t('journal.actorAgent') },
])
const projectOptions = computed(() => [
{ value: '', label: t('journal.filterProjectAll') },
...projects.value.map((p) => ({ value: String(p.id), label: p.name })),
])
// Имена проектов: запись журнала хранит только id (проект может быть удалён),
// поэтому в справочник идут и активные, и архивные — иначе у архивного проекта
// в журнале стояло бы «проект удалён». В фильтре — только активные.
const projectNames = ref(new Map<number, string>())
let loadSeq = 0
async function load() {
const seq = ++loadSeq
loading.value = true
error.value = ''
try {
const res = await api.listTaskEvents({
kind: filters.value.kind,
actor_kind: filters.value.actor,
project_id: filters.value.project === '' ? '' : Number(filters.value.project),
page: page.value,
per_page: PER_PAGE,
})
if (seq !== loadSeq) return
events.value = res.items
total.value = res.total
// страница могла опустеть (фильтр сузился) — уходим на последнюю
if (res.page !== page.value) page.value = res.page
} catch (e) {
if (seq !== loadSeq) return
error.value = String(e)
} finally {
if (seq === loadSeq) loading.value = false
}
}
// Смена фильтра — с первой страницы
watch(
filters,
() => {
page.value = 1
void load()
},
{ deep: true },
)
watch(page, () => void load())
onMounted(async () => {
await load()
try {
// Активные — для фильтра; архивные добавляем только в справочник имён
const [active, archived] = await Promise.all([
api.listProjects(false),
api.listProjects(true),
])
projects.value = active
projectNames.value = new Map([...active, ...archived].map((p) => [p.id, p.name]))
} catch (e) {
error.value = String(e)
}
})
// Живость: любое изменение задачи — новая запись в журнале
useRealtimeRefresh(load, ['task.changed', 'task.review', 'task.claimed', 'task.deleted'])
// Комментарий записи: у смены статуса и флага это машинный код
// («to_do→in_progress», «on»/«off») — интерфейс читает его своими словами,
// у остальных видов (закрытие, возврат, отказ) это обычный текст автора
function commentText(e: TaskEvent): string {
const c = e.comment ?? ''
if (!c) return ''
if (e.kind === 'status_changed') {
const [from, to] = c.split('→')
return `${statusLabel(from ?? '')} → ${statusLabel(to ?? '')}`
}
if (e.kind === 'eligible_changed') {
return c === 'on' ? t('journal.eligibleOn') : t('journal.eligibleOff')
}
return c
}
const columns = computed(() => [
{ key: 'created_at', label: t('journal.col.when') },
{ key: 'kind', label: t('journal.col.event') },
{ key: 'task_title', label: t('journal.col.task') },
{ key: 'project_id', label: t('journal.col.project') },
{ key: 'actor_name', label: t('journal.col.actor') },
{ key: 'comment', label: t('journal.col.comment') },
])
// «Откуда пришло» — значки вида канала: журнал нужен в том числе чтобы видеть,
// что действие пришло не из интерфейса
const VIA_ICONS: Record<string, string> = {
ui: 'ph-monitor',
api: 'ph-plug',
mcp: 'ph-cpu',
}
</script>
<template>
<section>
<GnPageHeader kicker="GNexus Tasks" :title="t('journal.title')">
<template #meta>{{ t('journal.count', { n: total }) }}</template>
<template #actions>
<div class="header-filters">
<div class="filter-box">
<GnSelect
v-model="filters.kind"
icon="ph-funnel"
:options="kindOptions"
:aria-label="t('journal.filterKind')"
/>
</div>
<div class="filter-box">
<GnSelect
v-model="filters.actor"
icon="ph-user-circle"
:options="actorOptions"
:aria-label="t('journal.filterActor')"
/>
</div>
<div class="filter-box filter-project">
<GnSelect
v-model="filters.project"
icon="ph-folder"
:options="projectOptions"
:aria-label="t('journal.filterProject')"
/>
</div>
</div>
<GnLoader
v-if="loading && events.length"
circle
label=""
:aria-label="t('common.loading')"
class="refresh-loader"
/>
</template>
</GnPageHeader>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<div v-if="loading && !events.length" class="journal-skeleton" aria-hidden="true">
<span v-for="i in 8" :key="i" class="skeleton skeleton-row" />
</div>
<GnTable
v-else
:columns="columns"
:rows="events"
:empty-text="t('journal.empty')"
class="journal-table"
>
<template #cell-created_at="{ row }">
<span class="when">{{ formatDateTime(row.created_at) }}</span>
</template>
<template #cell-kind="{ row }">
<GnBadge :variant="row.kind === 'rejected' ? 'warning' : 'secondary'">
{{ t(`journal.kind.${row.kind}`) }}
</GnBadge>
</template>
<template #cell-task_title="{ row }">
<RouterLink v-if="row.task_id" class="task-link" :to="`/tasks/${row.task_id}`">
{{ row.task_title }}
</RouterLink>
<!-- задача удалена: запись осталась снапшотом заголовка -->
<span v-else class="task-gone" :title="t('journal.taskDeleted')">{{ row.task_title }}</span>
</template>
<template #cell-project_id="{ row }">
<span v-if="row.project_id" class="project">
<i class="ph ph-folder" aria-hidden="true" />
{{ projectNames.get(row.project_id) ?? t('journal.projectGone') }}
</span>
</template>
<template #cell-actor_name="{ row }">
<span class="actor">
<i
:class="`ph ${row.actor_kind === 'agent' ? 'ph-sparkle' : 'ph-user'}`"
aria-hidden="true"
/>
<span class="actor-name">{{ row.actor_name }}</span>
<i
:class="`ph ${VIA_ICONS[row.via] ?? 'ph-monitor'} via`"
:title="t(`journal.via.${row.via}`)"
aria-hidden="true"
/>
</span>
</template>
<template #cell-comment="{ row }">
<span class="comment">{{ commentText(row) }}</span>
</template>
</GnTable>
<Pagination v-model:page="page" :total="total" :per-page="PER_PAGE" />
</section>
</template>
<style scoped>
/* Фильтры в шапке: три поля одного вида. Подписи сняты — вариант в поле
читается сам, а китовский запас под полем у form-group надо снять, иначе
поля не сходятся по вертикали с соседями (приём из ListView) */
.header-filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.filter-box {
width: 190px;
}
.filter-project {
min-width: 190px;
width: auto;
}
.filter-box :deep(.form-group) {
width: 100%;
min-width: 0;
margin-bottom: 0;
}
.filter-box :deep(.label) {
margin-bottom: 0;
}
.journal-skeleton {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.journal-skeleton .skeleton-row {
height: 42px;
border-radius: 3px;
}
/* Таблица журнала: комментарий — самая длинная колонка, ему и сжиматься */
.journal-table :deep(td) {
vertical-align: top;
}
.when {
white-space: nowrap;
font-size: 0.9em;
color: var(--text-muted, #888);
font-variant-numeric: tabular-nums;
}
.task-link {
color: inherit;
}
.task-gone {
color: var(--text-muted, #888);
}
.actor {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.actor-name {
overflow-wrap: anywhere;
}
.actor .via {
color: var(--text-muted, #888);
font-size: 0.9em;
}
.project {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.9em;
color: var(--text-muted, #888);
}
.comment {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 0.9em;
}
</style>