<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Tag, type Task } from '../api'
import { celebrateEarned } from '../gamification'
import { useRealtimeRefresh } from '../realtime'
import { statusOptions, statusIcon, gradeToPriority } from '../taskui'
import TaskCard from '../components/TaskCard.vue'
import Pagination from '../components/Pagination.vue'
// Классический вид (ТЗ 3.6): список с фильтрами — статус, приоритет, тег.
// Фильтры применяются сразу при смене значения.
const { t } = useI18n()
const tasks = ref<Task[]>([])
const tags = ref<Tag[]>([])
const error = ref('')
const loading = ref(false)
// Фильтра по проекту здесь нет (0.73): разрез по проектам — это раздел
// «Проекты», а в общем списке такой фильтр только дублировал его
const filters = ref({
status: 'to_do',
tag: '',
// Приоритет — переключателем (0.74): пять градаций в общем списке почти не
// нужны, а нужен один вопрос «показать главное». Порог — градация high
// (шкала: medium 5-6, high 7-8, urgent 9-10)
prioritized: false,
})
// Пагинация: по 30 карточек на страницу, смена фильтра — с первой
const page = ref(1)
const pagedTasks = computed(() =>
tasks.value.slice((page.value - 1) * 30, page.value * 30),
)
watch(
filters,
() => {
page.value = 1
},
// селекты мутируют вложенные поля — без deep watch молчит (ТЗ 3.6: смена
// фильтра возвращает на первую страницу)
{ deep: true },
)
// Токен запроса: поздний ответ старого фильтра не перетирает список нового
let loadSeq = 0
// Поиск по тегу (0.70) спрашивает сервер по подстроке: без задержки запрос уходил
// бы на каждую букву. Остальные фильтры (табы статуса, переключатель приоритета)
// грузят сразу — они меняются одним кликом.
// Пустой ввод — это в том числе сброс крестиком (0.71): его применяем сразу,
// задержка тут только мешала бы
let tagTimer: number | undefined
watch(
() => filters.value.tag,
(value) => {
window.clearTimeout(tagTimer)
if (!value) {
void load()
return
}
tagTimer = window.setTimeout(() => void load(), 250)
},
)
onBeforeUnmount(() => window.clearTimeout(tagTimer))
async function load() {
const seq = ++loadSeq
loading.value = true
error.value = ''
try {
const fetched = await api.listTasks({
detail_state: 'approved',
status: filters.value.status,
// тег ищется по подстроке имени (0.70) — фильтрует сервер
tag: filters.value.tag,
// «Приоритетные» (0.74) — отсечка по нижней границе шкалы: порог берём из
// taskui (gradeToPriority), чтобы он не разошёлся с градациями
priority_min: filters.value.prioritized ? gradeToPriority('high') : '',
})
if (seq !== loadSeq) return
tasks.value = fetched
} catch (e) {
if (seq !== loadSeq) return
error.value = String(e)
} finally {
if (seq === loadSeq) loading.value = false
}
}
onMounted(async () => {
await load()
try {
// опции фильтра тега: грузятся вместе со списком (не в try load) — но и они
// должны показывать ошибку, а не падать молча
tags.value = await api.listTags()
} catch (e) {
error.value = String(e)
}
})
// Реактивность (ТЗ 3.14): изменения задач/проектов (MCP, соседняя вкладка) —
// перечитываем список с текущими фильтрами
useRealtimeRefresh(load, ['task.changed', 'task.deleted', 'project.changed', 'project.deleted'])
async function setStatus(task: Task, status: string) {
error.value = ''
try {
await api.updateTask(task.id, { status })
celebrateEarned()
await load()
} catch (e) {
error.value = String(e)
}
}
// Удаление — через подтверждение
const deleteTarget = ref<Task | null>(null)
const confirmOpen = ref(false)
function askDelete(task: Task) {
deleteTarget.value = task
confirmOpen.value = true
}
async function remove() {
const task = deleteTarget.value
if (!task) return
confirmOpen.value = false
error.value = ''
try {
await api.deleteTask(task.id)
await load()
} catch (e) {
error.value = String(e)
}
}
// Фильтр статуса — табы (0.70): пять статусов из выпадашки не видны, а так
// состояние списка читается с одного взгляда. id «» = «Все» — пустой фильтр
const statusTabs = computed(() => [
{ id: '', label: t('common.all'), icon: 'ph-list-bullets' },
...statusOptions().map((o) => ({ id: o.value, label: o.label, icon: statusIcon(o.value) })),
])
// Переключателя приоритета в табах больше нет (0.74): из шести пунктов
// пользователю нужен был один — «приоритетные». Оставлен GnSwitch.
// Опции поиска тега: полный справочник, подсказки GnCombobox фильтрует сам
const tagOptions = computed(() => tags.value.map((tg) => ({ value: String(tg.id), label: tg.name })))
// Текст поиска тега без пробелов: по нему решаем, что показать в подсказках
// (совпавшие теги или подсказку «начните вводить») и нужен ли крестик сброса
const tagQuery = computed(() => filters.value.tag.trim())
// Сброс фильтра по тегу: watcher выше применит пустой ввод сразу, без debounce
function clearTag() {
filters.value.tag = ''
}
// Подпись у поля тега снята (0.73): фильтр в строке остался один — и фильтру
// статуса, и фильтру приоритета подписи не нужны, табы подписаны собой
</script>
<template>
<section>
<GnPageHeader kicker="GNexus Tasks" :title="t('list.title')">
<template #meta>{{ t('list.count', { n: tasks.length }) }}</template>
<!-- Фильтры-поля собраны в шапке (0.75): переключатель «Приоритетные» и поиск
по тегу стоят справа от заголовка одной группой. Отдельной строкой под
шапкой остались только табы статуса -->
<template #actions>
<div class="header-filters">
<!-- «Приоритетные» — переключатель кита (0.74). Задача без приоритета
считается самой низкой (very_low), поэтому в отсечку не попадает -->
<GnSwitch
:model-value="filters.prioritized"
:label="t('list.filterPrioritized')"
@update:model-value="
(v: boolean) => {
filters.prioritized = v
load()
}
"
/>
<!-- Тег — поиск по имени (0.70, доработано 0.71): селект из десятков тегов
не помогал найти нужный; ввод фильтрует список сразу, подсказки — сам
GnCombobox. Подсказки даём только под набранное, иначе на фокусе
выпадала простыня из всех тегов; обёртка задаёт ширину — class у кита
уходит на <input>, не на form-group -->
<div class="filter-tag">
<GnCombobox
v-model="filters.tag"
icon="ph-tag"
:options="tagQuery ? tagOptions : []"
:placeholder="t('list.filterTagSearch')"
:aria-label="t('list.filterTag')"
:not-found-text="tagQuery ? t('list.noTagsFound') : t('list.filterTagHint')"
/>
<!-- Сброс: у поиска нет пункта «— любой —», без крестика фильтр не снять -->
<GnIconButton
v-if="tagQuery"
class="filter-tag-clear"
icon="ph-x"
size="sm"
:label="t('list.filterTagClear')"
@click="clearTag"
/>
</div>
</div>
<!-- Спиннер обновления: данные уже есть, список дотягивается -->
<GnLoader
v-if="loading && tasks.length"
circle
label=""
:aria-label="t('common.loading')"
class="refresh-loader"
/>
</template>
</GnPageHeader>
<!-- Статус — табами (0.70): на узком экране табы прокручиваются по горизонтали
(кит: .tabs-list с overflow-x), страница по горизонтали не едет.
Приоритет табами был только в 0.73 и заменён переключателем (0.74) -->
<GnTabs
:model-value="filters.status"
:items="statusTabs"
compact
class="filter-tabs"
:aria-label="t('list.filterStatus')"
@update:model-value="
(v: string) => {
filters.status = v
load()
}
"
/>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<!-- Скелетон карточек задач: заголовок + ряд бейджей, как в TaskCard -->
<div v-if="loading" class="list-skeleton" aria-hidden="true">
<div v-for="i in 4" :key="i" class="card">
<div class="card-content">
<span class="skeleton skeleton-title" />
<div class="sk-badges">
<span v-for="j in 3" :key="j" class="skeleton skeleton-square" />
</div>
</div>
</div>
</div>
<GnEmptyState
v-if="!loading && tasks.length === 0"
icon="ph-magnifying-glass"
:title="t('list.emptyTitle')"
:text="t('list.emptyText')"
/>
<div class="task-list">
<TaskCard
v-for="task in pagedTasks"
:key="task.id"
:task="task"
show-project
@status-change="setStatus"
@delete="askDelete"
/>
</div>
<Pagination v-model:page="page" :total="tasks.length" />
<GnConfirmDialog
v-model:open="confirmOpen"
:title="t('common.confirmTitle')"
:message="t('common.deleteConfirmTask', { title: deleteTarget?.title ?? '' })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
confirm-variant="danger"
@confirm="remove"
/>
</section>
</template>
<style scoped>
/* Табы фильтра статуса (0.70): class ложится на корень GnTabs, поэтому без
:deep. Кит тянет табы на width:100%, а компактные — на max-width:620px:
шестой статус уезжал за край на десктопе. Возвращаем содержимому полную ширину.
Отступ снизу (0.78): 0.6rem было мало — табы липли к первой карточке списка,
отрываем их на 1.25rem (ровно тот же шаг, что у сетки задачи) */
.filter-tabs {
width: auto;
max-width: 100%;
margin-bottom: 1.25rem;
}
/* Полосу прокрутки у табов прячем (0.80): ряд прокручивается по горизонтали
(у кита .tabs-list{overflow-x:auto}), но видимая полоса тут только шумит —
обрезанный шестой таб и так читается как «дальше есть». Скролл остаётся.
`scrollbar-width: none` достаточно Chromium и Firefox; `::-webkit-scrollbar`
держим для Safari — там первое свойство не поддерживается */
.filter-tabs :deep(.tabs-list) {
scrollbar-width: none;
}
.filter-tabs :deep(.tabs-list)::-webkit-scrollbar {
display: none;
}
/* Фильтры в шапке (0.75): переключатель и поле тега — одной группой справа от
заголовка. Свой gap вместо китовских 8px у .page-header-actions: подпись
переключателя липла к коробке поля. Группа переносится по строкам на узком
экране сама (flex-wrap) */
.header-filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
/* Переключатель сдвинут на 3px вправо (0.80): у кита бегунок — квадрат 18×18
в `left:-5px` от коробки контрола, поэтому видимый левый край свитча на 3-5px
левее соседей по ряду (та же коробка поля тега стоит ровно). Это компенсация
выступа, а не отступ между элементами */
.header-filters :deep(.switch) {
margin-left: 3px;
}
/* Поле тега: ширину задаёт обёртка, потому что class из GnCombobox уходит на
<input>, а не на form-group. 300px — как на странице проекта (0.71): в 224px
имя тега не читалось */
.filter-tag {
position: relative;
width: 300px;
}
.filter-tag :deep(.form-group) {
width: 100%;
min-width: 0;
/* Обёртка должна совпадать с коробкой поля (0.76). У кита под полем остаётся
margin-bottom 15px у form-group, а над полем — 8px у input: из-за них поле
стояло на 3.5px выше центра ряда и не сходилось по вертикали с переключателем */
margin-bottom: 0;
}
.filter-tag :deep(.label) {
margin-bottom: 0;
}
.filter-tag :deep(input.input) {
margin-top: 0;
}
/* Иконка тега в поле — китовый ярус (bottom:1px, height:46px) в коробке поля
на пиксель ниже её центра, а глиф кажется ниже ещё на пару: поднимаем на 3px */
.filter-tag :deep(.label > .ph) {
transform: translateY(-3px);
}
/* Крестик сброса — внутри поля (0.71): своей кнопкой в ряду он не вписывался,
а слот внутрь GnCombobox кит не даёт, поэтому кладём его поверх поля.
Обёртка теперь ровно в коробку поля (50px, см. .form-group выше), значит кнопку
центрируем в этих 50px: 16px отсчитывались от обёртки с 15px запаса под полем
и кнопка уезжала к нижнему краю (0.76) */
.filter-tag-clear {
position: absolute;
right: 4px;
bottom: calc((50px - 28px) / 2);
}
/* текст поля не заезжает под крестик — отступ только когда крестик есть */
.filter-tag:has(.filter-tag-clear) :deep(input.input) {
padding-right: 2.4rem;
}
.list-skeleton {
margin-bottom: 1rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
/* на широком экране скелетон повторяет сетку списка (три в ряд) — загрузка
не дёргает раскладку */
@media (min-width: 1200px) {
.list-skeleton {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* карточка-скелет: как реальная карточка задачи — тянется на колонку */
.list-skeleton .card {
width: 100%;
max-width: none;
}
.list-skeleton .card-content > .skeleton-title {
display: block;
margin-bottom: 10px;
}
.sk-badges {
display: flex;
gap: 6px;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* на десктопе — карточки в две колонки, одной высоты в ряду.
minmax(0,1fr): трек 1fr не сжимается ниже min-content — длинные слова
в карточке распирают колонку за пределы контейнера (проверенный приём
мобильных фиксов) */
@media (min-width: 900px) {
.task-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.task-list :deep(.task-card) {
height: 100%;
}
}
/* с широким контейнером (0.68) — по три карточки в ряд; порог тот же, что у
проектов, чтобы ряды на соседних страницах совпадали по ширине */
@media (min-width: 1200px) {
.task-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>