9ff19f3cc3
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье», «стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно — надо резать», «надо не только кнопку показывать, а и что за ней». База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком (5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила. Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты; списания; безопасность и 2FA; отчёты; массовые действия; регистрация. Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ (окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки). 99 новых якорей data-tour расставлено по всему порталу (4 агента). Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля». Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии реально существует в разметке; экскурсии про действия обязаны открывать окно. Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения и перешла на второе место чистки. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> @
137 lines
4.3 KiB
Vue
137 lines
4.3 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* SessionsTable — активные сессии пользователя (UI-аудит 21.06.2026, Security).
|
|
*
|
|
* Реальные данные: GET /api/account/security → sessions (из user_sessions).
|
|
* «Завершить» (DELETE /api/account/sessions/{id}) реально убивает сессию
|
|
* (удаление из Redis). Заменяет прежний статичный mock из 3 фейк-сессий.
|
|
*/
|
|
import { onMounted, ref } from 'vue';
|
|
import { getAccountSecurity, revokeSession, type ActiveSession } from '../../../api/account';
|
|
|
|
const sessions = ref<ActiveSession[]>([]);
|
|
const loading = ref(true);
|
|
const fetchError = ref(false);
|
|
const revokingId = ref<number | null>(null);
|
|
const toastOpen = ref(false);
|
|
const toastText = ref('');
|
|
|
|
function formatRelative(iso: string | null): string {
|
|
if (!iso) return '';
|
|
const diffMs = Date.now() - new Date(iso).getTime();
|
|
const min = Math.floor(diffMs / 60000);
|
|
if (min < 1) return 'только что';
|
|
if (min < 60) return `${min} мин назад`;
|
|
const h = Math.floor(min / 60);
|
|
if (h < 24) return `${h} ч назад`;
|
|
const d = Math.floor(h / 24);
|
|
if (d < 30) return `${d} дн назад`;
|
|
return new Date(iso).toLocaleDateString('ru-RU');
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
loading.value = true;
|
|
fetchError.value = false;
|
|
try {
|
|
const data = await getAccountSecurity();
|
|
sessions.value = data.sessions;
|
|
} catch {
|
|
fetchError.value = true;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function revoke(id: number): Promise<void> {
|
|
revokingId.value = id;
|
|
try {
|
|
await revokeSession(id);
|
|
sessions.value = sessions.value.filter((s) => s.id !== id);
|
|
toastText.value = 'Сессия завершена.';
|
|
toastOpen.value = true;
|
|
} catch {
|
|
toastText.value = 'Не удалось завершить сессию.';
|
|
toastOpen.value = true;
|
|
} finally {
|
|
revokingId.value = null;
|
|
}
|
|
}
|
|
|
|
onMounted(load);
|
|
</script>
|
|
|
|
<template>
|
|
<v-card variant="outlined" class="pa-4" data-tour="settings-sessions">
|
|
<h3 class="text-subtitle-2 mb-3">Активные сессии</h3>
|
|
|
|
<v-alert
|
|
v-if="fetchError"
|
|
type="warning"
|
|
variant="tonal"
|
|
density="compact"
|
|
data-testid="sessions-error"
|
|
>
|
|
Не удалось загрузить сессии.
|
|
</v-alert>
|
|
|
|
<div v-else-if="loading" class="text-body-2 text-medium-emphasis">Загрузка…</div>
|
|
|
|
<p v-else-if="sessions.length === 0" class="text-body-2 text-medium-emphasis ma-0">
|
|
Активных сессий не найдено.
|
|
</p>
|
|
|
|
<ul v-else class="sessions-list">
|
|
<li v-for="s in sessions" :key="s.id" class="session-row">
|
|
<div class="session-info">
|
|
<div class="session-device">
|
|
{{ s.device }}
|
|
<v-chip v-if="s.current" color="primary" size="x-small" variant="tonal" class="ml-2">
|
|
эта сессия
|
|
</v-chip>
|
|
</div>
|
|
<div class="session-meta text-caption text-medium-emphasis">
|
|
{{ s.ip || 'IP неизвестен' }} · {{ formatRelative(s.at) }}
|
|
</div>
|
|
</div>
|
|
<v-btn
|
|
v-if="!s.current"
|
|
variant="text"
|
|
size="small"
|
|
color="error"
|
|
:loading="revokingId === s.id"
|
|
:data-testid="`revoke-session-${s.id}`"
|
|
@click="revoke(s.id)"
|
|
>
|
|
Завершить
|
|
</v-btn>
|
|
</li>
|
|
</ul>
|
|
|
|
<v-snackbar v-model="toastOpen" :timeout="3000" location="bottom right">
|
|
{{ toastText }}
|
|
</v-snackbar>
|
|
</v-card>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.sessions-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.session-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 10px 0;
|
|
border-bottom: 1px solid #f0ede4;
|
|
}
|
|
.session-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.session-device {
|
|
font-weight: 500;
|
|
color: #081319;
|
|
}
|
|
</style>
|