feat(tours): GuidedTour — обобщённый раннер с ожиданием цели

This commit is contained in:
Дмитрий
2026-07-03 03:23:59 +03:00
parent 8526fa3389
commit 9b5ddcb6cd
2 changed files with 241 additions and 0 deletions
@@ -0,0 +1,186 @@
<script setup lang="ts">
/**
* GuidedTour — обобщённый раннер экскурсий (спека ИИ-бота §4, этап 3).
* Отличия от WelcomeTour: шаги пропсом; цель шага может появиться ПОСЛЕ
* действия клиента (открыл диалог) — меряем с ретраем каждые 300мс до 15с.
* Разметка/стили — по образцу WelcomeTour (единый вид подсказок).
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { TourStep } from '../../tours/catalog';
const props = defineProps<{ steps: TourStep[]; active: boolean }>();
const emit = defineEmits<{ finish: [] }>();
const RETRY_MS = 300;
const RETRY_MAX = 50; // 15 сек
const stepIndex = ref(0);
const targetRect = ref<{ top: number; left: number; width: number; height: number } | null>(null);
let retryTimer: ReturnType<typeof setInterval> | null = null;
const currentStep = computed(() => props.steps[stepIndex.value]);
const isLast = computed(() => stepIndex.value === props.steps.length - 1);
function stopRetry(): void {
if (retryTimer !== null) {
clearInterval(retryTimer);
retryTimer = null;
}
}
function measure(): void {
stopRetry();
targetRect.value = null;
const sel = currentStep.value?.target;
if (!sel) return;
let attempts = 0;
const tryMeasure = (): void => {
const el = document.querySelector(sel);
if (el) {
const r = el.getBoundingClientRect();
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height };
stopRetry();
return;
}
attempts += 1;
if (attempts >= RETRY_MAX) stopRetry();
};
tryMeasure();
if (targetRect.value === null) {
retryTimer = setInterval(tryMeasure, RETRY_MS);
}
}
const highlightStyle = computed(() => {
const r = targetRect.value;
if (!r) return { display: 'none' };
const pad = 6;
return {
top: `${r.top - pad}px`,
left: `${r.left - pad}px`,
width: `${r.width + pad * 2}px`,
height: `${r.height + pad * 2}px`,
};
});
const tooltipStyle = computed(() => {
const r = targetRect.value;
if (!r) return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
return { top: `${Math.max(12, r.top)}px`, left: `${r.left + r.width + 16}px` };
});
function next(): void {
if (isLast.value) {
finish();
return;
}
stepIndex.value += 1;
measure();
}
function finish(): void {
stopRetry();
emit('finish');
}
function onResize(): void {
if (props.active) measure();
}
watch(
() => props.active,
(on) => {
if (on) {
stepIndex.value = 0;
requestAnimationFrame(() => measure());
window.addEventListener('resize', onResize);
} else {
stopRetry();
window.removeEventListener('resize', onResize);
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopRetry();
window.removeEventListener('resize', onResize);
});
defineExpose({ stepIndex, targetRect, next, finish });
</script>
<template>
<div v-if="active && currentStep" class="guided-tour" data-testid="guided-tour">
<div class="guided-tour__backdrop" />
<div v-if="targetRect" class="guided-tour__highlight" :style="highlightStyle" />
<div class="guided-tour__card" :style="tooltipStyle" role="dialog" aria-modal="true">
<div class="guided-tour__step">Шаг {{ stepIndex + 1 }} из {{ steps.length }}</div>
<h3 class="guided-tour__title">{{ currentStep.title }}</h3>
<p class="guided-tour__text">{{ currentStep.text }}</p>
<div class="guided-tour__actions">
<v-btn variant="text" size="small" data-testid="tour-skip" @click="finish">Закрыть</v-btn>
<v-btn color="primary" variant="flat" size="small" data-testid="tour-next" @click="next">
{{ isLast ? 'Готово' : 'Далее' }}
</v-btn>
</div>
</div>
</div>
</template>
<style scoped>
.guided-tour {
position: fixed;
inset: 0;
z-index: 3000;
pointer-events: none;
}
.guided-tour__backdrop {
position: absolute;
inset: 0;
background: rgba(1, 32, 25, 0.55);
pointer-events: auto;
}
.guided-tour__highlight {
position: absolute;
border: 2px solid var(--liderra-teal, #0f6e56);
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(1, 32, 25, 0.55);
transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.guided-tour__card {
position: absolute;
width: 300px;
max-width: calc(100vw - 24px);
background: #fff;
border-radius: 12px;
padding: 16px 18px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
pointer-events: auto;
}
.guided-tour__step {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #6b7470;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.guided-tour__title {
font-size: 16px;
font-weight: 600;
margin: 4px 0 6px;
color: #081319;
}
.guided-tour__text {
font-size: 13px;
line-height: 1.45;
color: #3a423f;
margin: 0 0 14px;
}
.guided-tour__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import GuidedTour from '../../resources/js/components/layout/GuidedTour.vue';
import type { TourStep } from '../../resources/js/tours/catalog';
const steps: TourStep[] = [
{ route: '/projects', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' },
{ route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' },
];
function mountTour() {
return mount(GuidedTour, {
props: { steps, active: true },
global: { stubs: { 'v-btn': { template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
});
}
describe('GuidedTour', () => {
it('показывает первый шаг и счётчик', () => {
const w = mountTour();
expect(w.text()).toContain('Шаг 1');
expect(w.text()).toContain('1 из 2');
});
it('Далее ведёт по шагам, на последнем — Готово и finish', async () => {
const w = mountTour();
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.text()).toContain('Шаг 2');
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('Пропустить завершает тур сразу', async () => {
const w = mountTour();
await w.find('[data-testid="tour-skip"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('цель не найдена → карточка по центру (targetRect null), без падения', () => {
const w = mountTour();
expect(w.find('[data-testid="guided-tour"]').exists()).toBe(true);
});
it('ретрай измерения: цель появляется позже — подсветка находит её', async () => {
vi.useFakeTimers();
const w = mountTour();
const el = document.createElement('div');
el.setAttribute('data-tour', 'a');
document.body.appendChild(el);
await vi.advanceTimersByTimeAsync(1000);
expect((w.vm as any).targetRect).not.toBeNull();
el.remove();
vi.useRealTimers();
});
});