<script setup lang="ts">
import { computed, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type TaskEvent } from '../api'
import { useRealtimeRefresh } from '../realtime'
import { formatDate, formatTime, 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 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
// Фильтра по проекту здесь нет (0.94): проектов у владельца будут сотни, и
// выпадающий список из них искать нужный не помогает — разрез по проектам
// живёт в самом разделе «Проекты» (та же логика, что в общем списке задач, 0.73)
const filters = ref({
kind: '',
actor: '' as '' | 'user' | 'agent',
})
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') },
])
// Имена проектов: запись журнала хранит только 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,
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 {
// Архивные проекты нужны только в справочнике имён: запись журнала хранит
// один id, а проект к моменту чтения мог уйти в архив
const [active, archived] = await Promise.all([
api.listProjects(false),
api.listProjects(true),
])
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
}
// Комментарий показываем иконкой и только когда он есть (0.94): записи журнала
// в основном без комментария, и колонка из пустых ячеек съедала половину ширины
// таблицы. Полный текст открывается модалкой — в ячейке он не влезал и рвал строку
const commentRow = ref<TaskEvent | null>(null)
const commentOpen = computed({
get: () => commentRow.value !== null,
set: (open: boolean) => {
if (!open) commentRow.value = null
},
})
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') },
// Заголовок колонки комментария — иконкой (0.94): слово «Комментарий» шире
// самой колонки, в которой теперь стоит кнопка. У GnTable нет слота заголовка,
// но она принимает в label готовый узел — иконке даём доступное имя.
// Обход описан в доке кита: gnexus-ui-kit/docs/tasks/gn-table-header-slot.md
// (там же просьба о слоте header-* и ручках колонок)
{
key: 'comment',
label: h('i', {
class: 'ph ph-chat-text',
role: 'img',
'aria-label': t('journal.col.comment'),
title: 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>
<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 }">
<!-- Дата и время — отдельными частями (0.94): на телефоне колонка узкая,
и они встают в две строки, а не растягивают таблицу -->
<span class="when">
<span>{{ formatDate(row.created_at) }}</span>
<span class="when-sep" aria-hidden="true">, </span>
<span>{{ formatTime(row.created_at) }}</span>
</span>
</template>
<template #cell-kind="{ row }">
<span class="event">
<GnBadge :variant="row.kind === 'rejected' ? 'warning' : 'secondary'">
{{ t(`journal.kind.${row.kind}`) }}
</GnBadge>
<!-- Дубль актора под бейджем — только для телефона (0.94): там его
колонка скрыта, а ради актора журнал и существует -->
<span class="actor actor-inline">
<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>
</span>
</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 }">
<!-- Комментарий — иконкой и только когда он есть (0.94): пустых ячеек
в журнале большинство, кнопка на каждую читалась бы шумом -->
<GnIconButton
v-if="commentText(row)"
icon="ph-chat-text"
size="sm"
:label="t('journal.showComment')"
@click="commentRow = row"
/>
</template>
</GnTable>
<Pagination v-model:page="page" :total="total" :per-page="PER_PAGE" />
<!-- Комментарий целиком: в ячейке он не влезал, а обрезать текст автора нельзя -->
<GnModal
:open="commentOpen"
:title="t('journal.col.comment')"
@update:open="commentOpen = $event"
>
<div v-if="commentRow" class="comment-modal">
<!-- Контекст записи: диалог открывается из строки таблицы, под ним
остаётся весь журнал — без этих слов непонятно, чей это комментарий -->
<p class="comment-context">
{{ t(`journal.kind.${commentRow.kind}`) }}
<template v-if="commentRow.task_title"> · {{ commentRow.task_title }}</template>
</p>
<p class="comment-full">{{ commentText(commentRow) }}</p>
</div>
<template #actions>
<GnButton @click="commentOpen = false">{{ t('common.close') }}</GnButton>
</template>
</GnModal>
</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-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 {
display: inline-flex;
align-items: baseline;
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-inline {
display: none;
font-size: 0.85em;
color: var(--text-muted, #888);
}
.event {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
}
.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-full {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.comment-context {
margin: 0 0 0.75rem;
color: var(--gn-color-text-muted, #787c99);
font-size: 0.9em;
overflow-wrap: anywhere;
}
/* Мобильные (0.94): шесть колонок в 390px не влезают даже с иконкой вместо
комментария. Сжимаем отступы ячеек, дату ставим в две строки, убираем две
справочные колонки и разрешаем переносы — таблица остаётся таблицей, но
перестаёт ехать по горизонтали */
@media (max-width: 767px) {
.journal-table :deep(.table-row td),
.journal-table :deep(.table-row th) {
padding: 8px 8px;
}
/* Бейдж события — самая широкая ячейка на телефоне: моноширинный капс
13px рвёт «ДОСТУПНОСТЬ» на две строки и задаёт таблице нижнюю границу
ширины. На телефоне он мельче и плотнее */
.journal-table :deep(.table-row .badge) {
font-size: 0.75rem;
padding: 4px 6px;
}
/* Колонка комментария — одна кнопка, отступы ячейки ей ни к чему */
.journal-table :deep(.table-row > :nth-child(6)) {
padding-left: 4px;
padding-right: 4px;
}
/* Проект уходит: его имя — самая длинная строка в таблице, а задача уже
связана со своим проектом ссылкой на странице задачи */
.journal-table :deep(.table-row > :nth-child(4)) {
display: none;
}
/* Актор уходит своей колонкой, но возвращается строкой под бейджем события:
он узкий и в колонке оставлял половину ширины пустой.
Колонки прячутся по номеру, потому что у columns кита нет ни class, ни
hidden — обход и просьба об этом: gnexus-ui-kit/docs/tasks/gn-table-header-slot.md */
.journal-table :deep(.table-row > :nth-child(5)) {
display: none;
}
.actor-inline {
display: inline-flex;
margin-top: 0.25rem;
}
.when {
flex-direction: column;
}
.when-sep {
display: none;
}
.actor {
white-space: normal;
}
}
</style>