(null)
+// busy: двойной клик по «Создать» не должен плодить две задачи
+const busy = ref(false)
async function save() {
+ if (busy.value) return
+ busy.value = true
try {
// GnTagInput подтверждает набираемый тег только по Enter/вставке: если
// текст остался в поле (клик по кнопке сразу после ввода), фиксируем его
@@ -210,6 +214,8 @@
emit('saved', task)
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
+ } finally {
+ busy.value = false
}
}
@@ -369,7 +375,7 @@
-
+
{{ submitText }}
diff --git a/frontend/src/realtime.ts b/frontend/src/realtime.ts
index 32c1578..97686a5 100644
--- a/frontend/src/realtime.ts
+++ b/frontend/src/realtime.ts
@@ -2,7 +2,7 @@
// События грубозернистые (kind + данные): подписчики реагируют refetch'ем.
// Reconnect: браузер сам переподключает EventSource, но при CLOSED-состоянии
// (сессия истекла / сеть лежит долго) включается backoff 1→2→5→15 с.
-import { onScopeDispose } from 'vue'
+import { getCurrentScope, onScopeDispose } from 'vue'
export interface RealtimeEvent {
kind: string
@@ -90,10 +90,15 @@
handlers.set(kind, set)
}
set.add(handler)
- return () => {
+ const off = () => {
set?.delete(handler)
if (set && set.size === 0) handlers.delete(kind)
}
+ // Подписка из setup-контекста компонента (типичный вид) умирает вместе с
+ // компонентом: без этого N визитов на страницу оставляли N живых обработчиков
+ // в модульном Map — каждое SSE-событие дёргало load() на отмонтированных.
+ if (getCurrentScope()) onScopeDispose(off)
+ return off
}
// Дедуп XP-тостов (окно 3 с): собственные действия празднуются по заголовкам
diff --git a/frontend/src/views/ListView.vue b/frontend/src/views/ListView.vue
index fff3a3b..d2fc81c 100644
--- a/frontend/src/views/ListView.vue
+++ b/frontend/src/views/ListView.vue
@@ -30,31 +30,50 @@
const pagedTasks = computed(() =>
tasks.value.slice((page.value - 1) * 30, page.value * 30),
)
-watch(filters, () => {
- page.value = 1
-})
+watch(
+ filters,
+ () => {
+ page.value = 1
+ },
+ // селекты мутируют вложенные поля — без deep watch молчит (ТЗ 3.6: смена
+ // фильтра возвращает на первую страницу)
+ { deep: true },
+)
+
+// Токен запроса: поздний ответ старого фильтра не перетирает список нового
+let loadSeq = 0
async function load() {
+ const seq = ++loadSeq
loading.value = true
error.value = ''
try {
- tasks.value = await api.listTasks({
+ const fetched = await api.listTasks({
detail_state: 'approved',
status: filters.value.status,
project_id: filters.value.project_id,
tag_id: filters.value.tag_id,
})
+ if (seq !== loadSeq) return
+ tasks.value = fetched
} catch (e) {
+ if (seq !== loadSeq) return
error.value = String(e)
} finally {
- loading.value = false
+ if (seq === loadSeq) loading.value = false
}
}
onMounted(async () => {
await load()
- projects.value = await api.listProjects()
- tags.value = await api.listTags()
+ try {
+ // опции фильтров: грузятся вместе со списком (не в try load) — но и они
+ // должны показывать ошибку, а не падать молча
+ projects.value = await api.listProjects()
+ tags.value = await api.listTags()
+ } catch (e) {
+ error.value = String(e)
+ }
})
// Реактивность (ТЗ 3.14): изменения задач/проектов (MCP, соседняя вкладка) —
diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue
index 62c0023..103c584 100644
--- a/frontend/src/views/ProjectView.vue
+++ b/frontend/src/views/ProjectView.vue
@@ -177,22 +177,34 @@
}
}
+// Токен запроса: поздний ответ для старого id (быстрая навигация по проектам)
+// не должен перетирать данные нового
+let loadSeq = 0
+
async function load() {
+ const seq = ++loadSeq
loading.value = true
notFound.value = false
error.value = ''
try {
- project.value = await api.getProject(projectId.value)
- tasks.value = await api.listTasks({ project_id: projectId.value })
- tags.value = await api.listTags()
+ const [fetched, projectTasks, tagList] = await Promise.all([
+ api.getProject(projectId.value),
+ api.listTasks({ project_id: projectId.value }),
+ api.listTags(),
+ ])
+ if (seq !== loadSeq) return
+ project.value = fetched
+ tasks.value = projectTasks
+ tags.value = tagList
} catch (e) {
+ if (seq !== loadSeq) return
if (e instanceof ApiError && e.status === 404) {
notFound.value = true
} else {
error.value = String(e)
}
} finally {
- loading.value = false
+ if (seq === loadSeq) loading.value = false
}
}
diff --git a/frontend/src/views/StackView.vue b/frontend/src/views/StackView.vue
index e320a44..27cfa85 100644
--- a/frontend/src/views/StackView.vue
+++ b/frontend/src/views/StackView.vue
@@ -70,9 +70,14 @@
onMounted(() => loadAll())
+// busy: двойной Enter до очистки поля не должен создавать дубликаты
+const quickBusy = ref(false)
+
async function quickAdd() {
+ if (quickBusy.value) return
const title = quickTitle.value.trim()
if (!title) return
+ quickBusy.value = true
try {
await api.createTask(title)
quickTitle.value = ''
@@ -83,6 +88,8 @@
await loadAll()
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
+ } finally {
+ quickBusy.value = false
}
}
diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue
index 80ade65..4c56074 100644
--- a/frontend/src/views/TaskView.vue
+++ b/frontend/src/views/TaskView.vue
@@ -69,27 +69,43 @@
setPageTitle(task.value?.title ?? t('nav.task'))
})
+// Токен запроса: при быстрой навигации (id сменился) ответ старого getTask,
+// пришедший позже, не должен перетереть данные новой задачи
+let loadSeq = 0
+
async function load() {
+ const seq = ++loadSeq
loading.value = true
notFound.value = false
error.value = ''
try {
- task.value = await api.getTask(taskId.value)
- if (task.value.document_id !== null) {
- attachments.value = await api.listAttachments(task.value.document_id)
+ const fetched = await api.getTask(taskId.value)
+ if (seq !== loadSeq) return
+ task.value = fetched
+ if (fetched.document_id !== null) {
+ const atts = await api.listAttachments(fetched.document_id)
+ if (seq !== loadSeq) return
+ attachments.value = atts
}
// все задачи (не только утверждённые) — подзадача видна сразу после создания
- tasks.value = await api.listTasks()
- tagNames.value = task.value.tags.map((t0) => t0.name)
- if (!tagsCatalog.value.length) tagsCatalog.value = await api.listTags()
+ const all = await api.listTasks()
+ if (seq !== loadSeq) return
+ tasks.value = all
+ tagNames.value = fetched.tags.map((t0) => t0.name)
+ if (!tagsCatalog.value.length) {
+ const catalog = await api.listTags()
+ if (seq !== loadSeq) return
+ tagsCatalog.value = catalog
+ }
} catch (e) {
+ if (seq !== loadSeq) return
if (e instanceof ApiError && e.status === 404) {
notFound.value = true
} else {
error.value = String(e)
}
} finally {
- loading.value = false
+ if (seq === loadSeq) loading.value = false
}
}
@@ -105,9 +121,13 @@
})
// Переход «подзадача → родитель» (и любой /tasks/:id → /tasks/:id) переиспользует
-// этот же инстанс компонента — onMounted не сработает; перезагружаемся по смене id
+// этот же инстанс компонента — onMounted не сработает; перезагружаемся по смене id.
+// Черновики инлайн-правок сбрасываем обязательно: иначе открытый редактор описания
+// старой задачи уедет PATCH'ем в новую (тихая порча данных).
watch(taskId, () => {
editing.value = false
+ descEditing.value = false
+ draftDescription.value = ''
void load()
})
@@ -564,7 +584,7 @@
-
+