849 lines
34 KiB
Vue
849 lines
34 KiB
Vue
<template>
|
||
<div data-theme="winter" class="min-h-screen text-base-content">
|
||
<NuxtRouteAnnouncer />
|
||
<canvas id="canvas-basic" class="gradient-canvas" aria-hidden="true"></canvas>
|
||
<div class="relative z-10">
|
||
<aside
|
||
class="glass-panel fixed left-4 top-4 z-20 flex h-[calc(100vh-2rem)] w-[360px] flex-col rounded-3xl p-5"
|
||
>
|
||
<div class="card glass-pane mb-3 rounded-2xl">
|
||
<div class="card-body py-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="avatar">
|
||
<div class="w-12 rounded-full bg-gradient-to-br from-fuchsia-400 via-violet-400 to-cyan-400 p-[2px]">
|
||
<div class="flex h-full w-full items-center justify-center rounded-full bg-base-100">
|
||
<span class="text-lg">🤖</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span class="text-sm font-semibold text-left">Хантик</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex-1 space-y-4 overflow-auto pr-1">
|
||
<div
|
||
v-for="message in chatHistory"
|
||
:key="message.id"
|
||
class="chat"
|
||
:class="message.from === 'ai' ? 'chat-start' : 'chat-end'"
|
||
>
|
||
<div v-if="message.from === 'ai'" class="chat-image avatar">
|
||
<div class="w-8 rounded-full bg-gradient-to-br from-fuchsia-400 via-violet-400 to-cyan-400 p-[1px]">
|
||
<div class="flex h-full w-full items-center justify-center rounded-full bg-base-100">
|
||
<span class="text-xs">🤖</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="chat-bubble text-sm"
|
||
:class="message.from === 'ai' ? 'glass-pane' : 'bg-sky-500/20'"
|
||
@click="message.step !== undefined && goToStep(message.step)"
|
||
>
|
||
{{ message.text }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-5">
|
||
<div class="card glass-pane rounded-2xl">
|
||
<div class="card-body p-3">
|
||
<div class="join w-full">
|
||
<input class="input input-bordered input-lg join-item w-full" placeholder="Напишите сообщение" />
|
||
<button
|
||
class="btn btn-primary btn-lg join-item"
|
||
type="button"
|
||
aria-label="Отправить"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-5 w-5" fill="currentColor" aria-hidden="true">
|
||
<path d="M3.4 20.6 22 12 3.4 3.4l1.9 6.4L16 12 5.3 14.2z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main
|
||
class="min-h-screen w-full px-4 py-6 md:px-8 lg:pl-[400px]"
|
||
>
|
||
<section class="glass-panel flex min-h-[calc(100vh-3rem)] flex-col gap-6 rounded-3xl p-6">
|
||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||
<div>
|
||
<h1 class="section-title">{{ steps[currentStep].title }}</h1>
|
||
</div>
|
||
</div>
|
||
<div class="w-full">
|
||
<div class="steps steps-horizontal w-full">
|
||
<button
|
||
v-for="(step, index) in steps"
|
||
:key="step.id"
|
||
class="step cursor-pointer"
|
||
:class="index <= currentStep ? 'step-primary' : ''"
|
||
type="button"
|
||
@click="goToStep(index)"
|
||
>
|
||
<span class="sr-only">{{ step.title }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="currentStep === 0" class="grid gap-4 grid-cols-[minmax(0,2.6fr)_minmax(0,1fr)]">
|
||
<div class="min-w-0 space-y-6 text-[18px] leading-relaxed text-slate-900">
|
||
<input class="input input-bordered input-lg w-full text-base text-slate-900" value="Head of Growth" />
|
||
<div>
|
||
<p class="text-lg font-semibold text-slate-900">Общее описание</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Мы ищем Head of Growth, который построит и масштабирует систему роста продукта: от активации и
|
||
первого успеха пользователя до удержания и расширения выручки. В роли нужно соединять продукт,
|
||
маркетинг, аналитику и продажи в единую growth‑машину, которая стабильно генерирует результат.
|
||
</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Это позиция для человека, который умеет превращать хаотичные идеи в управляемый pipeline
|
||
экспериментов, и не боится ответственности за цифры: активацию, конверсию, NRR и LTV.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="text-lg font-semibold text-slate-900">Ключевые зоны ответственности</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Построить backlog гипотез и систему их приоритизации, запустить регулярные спринты
|
||
growth‑экспериментов и довести их до измеримого эффекта. Настроить единый аналитический контур
|
||
(события, воронки, когорты, атрибуция) и дать команде прозрачный обзор того, где теряются
|
||
пользователи и деньги.
|
||
</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Объединить product‑маркетинг, performance и product‑аналитику вокруг одного North Star и
|
||
превратить его в рабочий инструмент для всех команд. Отвечать за рост ключевых метрик на уровне
|
||
квартала и года.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="text-lg font-semibold text-slate-900">Основные цели на 90 дней</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Развернуть growth‑плейбук: определить метрики, собрать список гипотез, настроить процесс
|
||
экспериментов и выдать первые устойчивые инкременты в активации и удержании. Сформировать
|
||
отчётность и ритм, который позволит команде видеть эффект каждую неделю.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="text-lg font-semibold text-slate-900">Взаимодействие с командами</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Эта роль работает на пересечении продукта, маркетинга, продаж и CX. Вам нужно держать единый
|
||
контекст по воронке, предлагать решения, которые одинаково хорошо живут и в продукте, и в
|
||
каналах привлечения.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="text-lg font-semibold text-slate-900">Пример рабочего дня</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Утро — разбор когорт и планирование новых экспериментов с продактом. Днём — синки с маркетингом и
|
||
аналитикой, согласование приоритетов и ресурса. Вечером — фиксация результатов спринта,
|
||
подготовка гипотез на следующую итерацию и обновление roadmap.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="text-lg font-semibold text-slate-900">Каким мы видим кандидата</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Опыт в продуктовой или growth‑роли с измеримым вкладом в метрики, сильные навыки работы с
|
||
данными, умение строить эксперименты и убеждать кросс‑функциональные команды работать на один
|
||
результат.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="text-lg font-semibold text-slate-900">О компании и продукте</p>
|
||
<p class="mt-3 text-slate-900">
|
||
Мы — B2B SaaS с международной аудиторией, продуктом пользуются тысячи команд в Европе и США.
|
||
Сейчас фокус на масштабировании выручки и усилении ретеншна. Роль — ключевая, напрямую влияет на
|
||
стратегию роста компании.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="min-w-0 space-y-4 max-w-[360px]">
|
||
<div class="glass-card relative cursor-pointer rounded-2xl p-5">
|
||
<button class="btn btn-ghost btn-circle btn-sm absolute right-4 top-4">▶</button>
|
||
<div class="voice-wave">
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
<span class="voice-bar"></span>
|
||
</div>
|
||
<p class="mt-4 text-sm text-slate-900">
|
||
Пройдите голосовое интервью — мы заполним всё за вас.
|
||
</p>
|
||
</div>
|
||
<div class="glass-card rounded-2xl p-5">
|
||
<h3 class="font-display text-base font-semibold sr-only">Целевой профиль кандидата</h3>
|
||
<div class="mt-4">
|
||
<ClientOnly>
|
||
<div ref="vacancyChartEl" class="h-64 w-full"></div>
|
||
</ClientOnly>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="currentStep === 1" class="grid gap-4">
|
||
<div class="mt-2 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||
<div class="glass-card rounded-2xl p-6 min-h-[180px]">
|
||
<div class="flex h-full flex-col items-center justify-center gap-4">
|
||
<div class="avatar">
|
||
<div class="w-16 rounded-full">
|
||
<img
|
||
alt="Аня"
|
||
src="https://i.pravatar.cc/160?img=12"
|
||
class="h-full w-full rounded-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p class="text-sm font-semibold">Аня · Product</p>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card rounded-2xl p-6 min-h-[180px]">
|
||
<div class="flex h-full flex-col items-center justify-center gap-4">
|
||
<div class="avatar">
|
||
<div class="w-16 rounded-full">
|
||
<img
|
||
alt="Сергей"
|
||
src="https://i.pravatar.cc/160?img=32"
|
||
class="h-full w-full rounded-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p class="text-sm font-semibold">Сергей · Growth</p>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card rounded-2xl p-6 min-h-[180px]">
|
||
<div class="flex h-full flex-col items-center justify-center gap-4">
|
||
<div class="avatar">
|
||
<div class="w-16 rounded-full">
|
||
<img
|
||
alt="Кира"
|
||
src="https://i.pravatar.cc/160?img=47"
|
||
class="h-full w-full rounded-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p class="text-sm font-semibold">Кира · Ops</p>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card rounded-2xl border border-dashed border-white/20 p-4">
|
||
<div class="flex h-full flex-col justify-between">
|
||
<div class="flex flex-1 items-center justify-center">
|
||
<button class="btn btn-ghost btn-sm">Добавить видео</button>
|
||
</div>
|
||
<div class="mt-4 flex w-full items-center justify-between gap-3">
|
||
<input class="input input-bordered input-sm flex-1" value="https://hr.example/team-video" />
|
||
<button class="btn btn-ghost btn-sm">Поделиться</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="currentStep === 2" class="grid gap-4">
|
||
<div class="glass-card rounded-2xl p-5">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="font-display text-lg font-semibold">Анализ видео</h3>
|
||
</div>
|
||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||
<span class="loading loading-spinner loading-lg text-info"></span>
|
||
<progress class="progress progress-info w-48" value="72" max="100"></progress>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card rounded-2xl p-8">
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<h3 class="font-display text-lg font-semibold sr-only">Требования на основе команды</h3>
|
||
</div>
|
||
<div class="mt-6">
|
||
<ClientOnly>
|
||
<div ref="teamChartEl" class="h-80 w-full"></div>
|
||
</ClientOnly>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="currentStep === 3" class="space-y-4">
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<h3 class="font-display text-lg font-semibold">Критерии совпадений</h3>
|
||
</div>
|
||
<div class="join rounded-full border border-white/10 p-1">
|
||
<button
|
||
v-for="method in methodologies"
|
||
:key="method.id"
|
||
class="btn btn-sm join-item"
|
||
:class="currentMethodology === method.id ? 'btn-primary' : 'btn-ghost'"
|
||
@click="currentMethodology = method.id"
|
||
>
|
||
{{ method.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<transition-group name="reorder" tag="div" class="grid gap-4 md:grid-cols-2">
|
||
<div
|
||
v-for="candidate in sortedCandidates"
|
||
:key="candidate.id"
|
||
class="glass-card min-h-[320px] rounded-2xl p-4"
|
||
>
|
||
<div class="flex items-start justify-between">
|
||
<div>
|
||
<div class="flex items-center gap-3">
|
||
<div class="avatar">
|
||
<div class="w-10 rounded-full">
|
||
<img
|
||
:alt="candidate.name"
|
||
:src="candidate.avatar"
|
||
class="h-full w-full rounded-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-display text-lg font-semibold">{{ candidate.name }}</h4>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-sm font-semibold text-fuchsia-600">
|
||
{{ candidate.match }}%
|
||
</div>
|
||
</div>
|
||
<div class="mt-4">
|
||
<ClientOnly>
|
||
<div
|
||
class="h-56 w-full"
|
||
:ref="setCandidateChartRef(candidate.id)"
|
||
></div>
|
||
</ClientOnly>
|
||
</div>
|
||
</div>
|
||
</transition-group>
|
||
|
||
</div>
|
||
|
||
<div v-else class="space-y-4">
|
||
<div class="space-y-3">
|
||
<div class="glass-card rounded-2xl p-4">
|
||
<div class="flex items-center justify-between gap-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="avatar">
|
||
<div class="w-10 rounded-full">
|
||
<img
|
||
alt="Ирина"
|
||
src="https://i.pravatar.cc/120?img=5"
|
||
class="h-full w-full rounded-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p class="text-sm font-semibold">Ирина Соколова · Head of Growth</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-1" class="radio radio-sm" />
|
||
Сегодня 16:00
|
||
</label>
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-1" class="radio radio-sm" />
|
||
Завтра 11:30
|
||
</label>
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-1" class="radio radio-sm" />
|
||
Ср 15:00
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card rounded-2xl p-4">
|
||
<div class="flex items-center justify-between gap-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="avatar">
|
||
<div class="w-10 rounded-full">
|
||
<img
|
||
alt="Кира"
|
||
src="https://i.pravatar.cc/120?img=47"
|
||
class="h-full w-full rounded-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p class="text-sm font-semibold">Кира Дуброва · Product Growth</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-2" class="radio radio-sm" />
|
||
Сегодня 18:00
|
||
</label>
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-2" class="radio radio-sm" />
|
||
Чт 12:00
|
||
</label>
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-2" class="radio radio-sm" />
|
||
Пт 10:30
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card rounded-2xl p-4">
|
||
<div class="flex items-center justify-between gap-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="avatar">
|
||
<div class="w-10 rounded-full">
|
||
<img
|
||
alt="Алексей"
|
||
src="https://i.pravatar.cc/120?img=51"
|
||
class="h-full w-full rounded-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p class="text-sm font-semibold">Алексей Марков · Growth Manager</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-3" class="radio radio-sm" />
|
||
Ср 14:30
|
||
</label>
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-3" class="radio radio-sm" />
|
||
Чт 16:00
|
||
</label>
|
||
<label class="flex items-center gap-2 text-xs text-slate-900">
|
||
<input type="radio" name="meet-3" class="radio radio-sm" />
|
||
Пт 09:30
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="flex justify-center">
|
||
<button class="btn btn-primary btn-lg">Запланировать встречу</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-auto flex justify-end" v-if="currentStep !== steps.length - 1">
|
||
<button class="btn btn-primary btn-lg" @click="nextStep">
|
||
Далее
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, nextTick, ref, watch } from 'vue'
|
||
import Granim from 'granim'
|
||
|
||
const steps = [
|
||
{
|
||
id: 'requirements',
|
||
title: 'Сформируйте требования к вакансии',
|
||
},
|
||
{
|
||
id: 'video',
|
||
title: 'Загрузите информацию о команде',
|
||
},
|
||
{
|
||
id: 'fingerprint',
|
||
title: 'Требования на основе команды',
|
||
},
|
||
{
|
||
id: 'recommendations',
|
||
title: 'Ознакомьтесь с кандидатами',
|
||
},
|
||
{
|
||
id: 'matches',
|
||
title: 'Запланируйте встречу',
|
||
}
|
||
]
|
||
|
||
const route = typeof useRoute === 'function' ? useRoute() : null
|
||
const router = typeof useRouter === 'function' ? useRouter() : null
|
||
const currentStep = ref(0)
|
||
const chatHistory = computed(() => chatByStep.slice(0, currentStep.value + 1).flat())
|
||
const goToStep = (index: number) => {
|
||
currentStep.value = Math.min(Math.max(index, 0), steps.length - 1)
|
||
}
|
||
|
||
const nextStep = () => {
|
||
goToStep(currentStep.value + 1)
|
||
}
|
||
|
||
const methodologies = [
|
||
{ id: 'structured', label: 'Структурированный разбор' },
|
||
{ id: 'situational', label: 'Ситуационный мэтч' },
|
||
{ id: 'role', label: 'Ролевой профиль' }
|
||
]
|
||
|
||
const axisLabelsByMethodology = {
|
||
structured: [
|
||
'Коммуникация/ясность',
|
||
'Решение проблем',
|
||
'Взаимодействие',
|
||
'Ownership/ответственность',
|
||
'Профессиональный опыт'
|
||
],
|
||
situational: [
|
||
'Конфликт-менеджмент',
|
||
'Переговоры',
|
||
'Командная работа',
|
||
'Межличностные навыки',
|
||
'Решение проблем в контексте роли'
|
||
],
|
||
role: ['Знания', 'Навыки', 'Умения', 'Рабочие активности', 'Контекст/условия роли']
|
||
} as const
|
||
|
||
const vacancyProfile = {
|
||
labels: [
|
||
'Коммуникация/ясность',
|
||
'Решение проблем',
|
||
'Ownership/ответственность',
|
||
'Взаимодействие',
|
||
'Темп/инициатива'
|
||
],
|
||
values: [80, 78, 82, 74, 76]
|
||
}
|
||
const teamProfile = {
|
||
labels: [
|
||
'Командная работа',
|
||
'Конфликт-менеджмент',
|
||
'Межличностные навыки',
|
||
'Взаимодействие',
|
||
'Контекст/условия роли'
|
||
],
|
||
values: [81, 73, 77, 75, 74]
|
||
}
|
||
|
||
const candidateProfiles = [
|
||
{
|
||
id: 1,
|
||
name: 'Ирина Соколова',
|
||
role: 'Growth Lead · B2B SaaS',
|
||
location: 'Берлин / Remote',
|
||
avatar: 'https://i.pravatar.cc/120?img=5',
|
||
tags: ['Experimentation', 'B2B', 'Lifecycle'],
|
||
highlight: 'темп запуска',
|
||
matchByMethodology: { structured: 91, situational: 84, role: 83 },
|
||
valuesByMethodology: {
|
||
structured: [88, 62, 79, 71, 66],
|
||
situational: [70, 86, 60, 75, 78],
|
||
role: [84, 64, 78, 76, 71]
|
||
}
|
||
},
|
||
{
|
||
id: 2,
|
||
name: 'Максим Орлов',
|
||
role: 'Head of Growth',
|
||
location: 'Лондон / Remote',
|
||
avatar: 'https://i.pravatar.cc/120?img=15',
|
||
tags: ['SQL', 'Funnels', 'Team Lead'],
|
||
highlight: 'структура',
|
||
matchByMethodology: { structured: 87, situational: 91, role: 90 },
|
||
valuesByMethodology: {
|
||
structured: [76, 58, 82, 68, 73],
|
||
situational: [64, 82, 58, 72, 83],
|
||
role: [80, 62, 74, 72, 69]
|
||
}
|
||
},
|
||
{
|
||
id: 3,
|
||
name: 'Лия Вернер',
|
||
role: 'Revenue Strategist',
|
||
location: 'Амстердам',
|
||
avatar: 'https://i.pravatar.cc/120?img=36',
|
||
tags: ['PLG', 'Retention', 'Ops'],
|
||
highlight: 'удержание',
|
||
matchByMethodology: { structured: 85, situational: 79, role: 77 },
|
||
valuesByMethodology: {
|
||
structured: [72, 74, 65, 70, 81],
|
||
situational: [62, 80, 61, 69, 79],
|
||
role: [78, 66, 69, 74, 70]
|
||
}
|
||
},
|
||
{
|
||
id: 4,
|
||
name: 'Алексей Нестеров',
|
||
role: 'Growth Manager',
|
||
location: 'Барселона',
|
||
avatar: 'https://i.pravatar.cc/120?img=51',
|
||
tags: ['Paid', 'Analytics', 'B2B'],
|
||
highlight: 'скорость экспериментов',
|
||
matchByMethodology: { structured: 83, situational: 88, role: 88 },
|
||
valuesByMethodology: {
|
||
structured: [70, 60, 78, 64, 69],
|
||
situational: [66, 78, 63, 67, 74],
|
||
role: [74, 60, 71, 68, 73]
|
||
}
|
||
},
|
||
{
|
||
id: 5,
|
||
name: 'Анна Прайс',
|
||
role: 'Lifecycle Director',
|
||
location: 'Прага',
|
||
avatar: 'https://i.pravatar.cc/120?img=64',
|
||
tags: ['CRM', 'Email', 'Segmentation'],
|
||
highlight: 'процессы',
|
||
matchByMethodology: { structured: 81, situational: 82, role: 92 },
|
||
valuesByMethodology: {
|
||
structured: [68, 72, 59, 66, 84],
|
||
situational: [60, 76, 57, 70, 77],
|
||
role: [70, 58, 75, 66, 68]
|
||
}
|
||
},
|
||
{
|
||
id: 6,
|
||
name: 'Давид Клайн',
|
||
role: 'Growth Partner',
|
||
location: 'Цюрих',
|
||
avatar: 'https://i.pravatar.cc/120?img=68',
|
||
tags: ['Strategy', 'OKR', 'B2B'],
|
||
highlight: 'стратегия',
|
||
matchByMethodology: { structured: 79, situational: 86, role: 81 },
|
||
valuesByMethodology: {
|
||
structured: [66, 64, 73, 62, 70],
|
||
situational: [62, 74, 56, 68, 72],
|
||
role: [68, 56, 73, 64, 66]
|
||
}
|
||
}
|
||
] as const
|
||
|
||
const currentMethodology = ref<'structured' | 'situational' | 'role'>('structured')
|
||
const currentCandidates = computed(() =>
|
||
candidateProfiles.map((candidate) => ({
|
||
...candidate,
|
||
match: candidate.matchByMethodology[currentMethodology.value],
|
||
values: candidate.valuesByMethodology[currentMethodology.value]
|
||
}))
|
||
)
|
||
const sortedCandidates = computed(() =>
|
||
[...currentCandidates.value].sort((a, b) => b.match - a.match)
|
||
)
|
||
|
||
const currentCandidateAxisLabels = computed(
|
||
() => axisLabelsByMethodology[currentMethodology.value]
|
||
)
|
||
|
||
const vacancyChartEl = ref<HTMLDivElement | null>(null)
|
||
const teamChartEl = ref<HTMLDivElement | null>(null)
|
||
const candidateChartEls = new Map<number, HTMLDivElement>()
|
||
const candidateCharts = new Map<number, any>()
|
||
let vacancyChart: any = null
|
||
let teamChart: any = null
|
||
let echarts: any = null
|
||
let granimInstance: any = null
|
||
|
||
const setCandidateChartRef = (id: number) => (el: HTMLDivElement | null) => {
|
||
if (el) {
|
||
candidateChartEls.set(id, el)
|
||
return
|
||
}
|
||
candidateChartEls.delete(id)
|
||
}
|
||
|
||
const buildRadarOption = (labels: string[], values: number[]) => ({
|
||
tooltip: {
|
||
trigger: 'item',
|
||
formatter: (params: { value: number[] }) =>
|
||
labels.map((label, index) => `${label}: ${params.value[index]}%`).join('<br/>')
|
||
},
|
||
radar: {
|
||
indicator: labels.map((label) => ({ name: label, max: 100 })),
|
||
splitNumber: 4,
|
||
axisName: { color: '#0f172a', fontSize: 10, fontWeight: 600 },
|
||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.3)' } },
|
||
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.2)' } },
|
||
splitArea: {
|
||
areaStyle: {
|
||
color: ['rgba(255, 255, 255, 0.3)', 'rgba(255, 255, 255, 0.6)']
|
||
}
|
||
}
|
||
},
|
||
series: [
|
||
{
|
||
type: 'radar',
|
||
data: [
|
||
{
|
||
value: values,
|
||
areaStyle: { color: 'rgba(56, 189, 248, 0.35)' },
|
||
lineStyle: { color: 'rgba(56, 189, 248, 0.9)', width: 2 },
|
||
symbol: 'circle',
|
||
symbolSize: 4
|
||
}
|
||
]
|
||
}
|
||
]
|
||
})
|
||
|
||
const ensureEcharts = async () => {
|
||
if (echarts) return
|
||
const mod = await import('echarts')
|
||
echarts = mod.default ?? mod
|
||
}
|
||
|
||
const renderVacancyChart = () => {
|
||
if (!echarts || !vacancyChartEl.value) return
|
||
if (!vacancyChart) {
|
||
vacancyChart = echarts.init(vacancyChartEl.value)
|
||
}
|
||
vacancyChart.setOption(buildRadarOption(vacancyProfile.labels, vacancyProfile.values))
|
||
}
|
||
|
||
const renderTeamChart = () => {
|
||
if (!echarts || !teamChartEl.value) return
|
||
if (!teamChart) {
|
||
teamChart = echarts.init(teamChartEl.value)
|
||
}
|
||
teamChart.setOption(buildRadarOption(teamProfile.labels, teamProfile.values))
|
||
}
|
||
|
||
const renderCandidateCharts = () => {
|
||
if (!echarts) return
|
||
const labels = currentCandidateAxisLabels.value
|
||
const candidateIds = new Set(sortedCandidates.value.map((candidate) => candidate.id))
|
||
|
||
for (const [id, chart] of candidateCharts) {
|
||
if (!candidateIds.has(id)) {
|
||
chart.dispose()
|
||
candidateCharts.delete(id)
|
||
}
|
||
}
|
||
|
||
sortedCandidates.value.forEach((candidate) => {
|
||
const el = candidateChartEls.get(candidate.id)
|
||
if (!el) return
|
||
const existing = candidateCharts.get(candidate.id)
|
||
if (existing) {
|
||
existing.setOption(buildRadarOption(labels, candidate.values))
|
||
return
|
||
}
|
||
const chart = echarts.init(el)
|
||
chart.setOption(buildRadarOption(labels, candidate.values))
|
||
candidateCharts.set(candidate.id, chart)
|
||
})
|
||
}
|
||
|
||
const syncStepFromRoute = () => {
|
||
if (!route) return
|
||
const raw = Array.isArray(route.query.step) ? route.query.step[0] : route.query.step
|
||
const parsed = Number(raw)
|
||
if (!Number.isNaN(parsed)) {
|
||
goToStep(parsed - 1)
|
||
}
|
||
}
|
||
|
||
const handleResize = () => {
|
||
vacancyChart?.resize()
|
||
teamChart?.resize()
|
||
candidateCharts.forEach((chart) => chart.resize())
|
||
}
|
||
|
||
onMounted(async () => {
|
||
syncStepFromRoute()
|
||
granimInstance = new Granim({
|
||
element: '#canvas-basic',
|
||
direction: 'radial',
|
||
isPausedWhenNotInView: true,
|
||
states: {
|
||
'default-state': {
|
||
gradients: [
|
||
['#ff9966', '#ff5e62'],
|
||
['#00F260', '#0575E6'],
|
||
['#e1eec3', '#f05053']
|
||
]
|
||
}
|
||
}
|
||
})
|
||
await ensureEcharts()
|
||
renderVacancyChart()
|
||
renderTeamChart()
|
||
renderCandidateCharts()
|
||
window.addEventListener('resize', handleResize)
|
||
})
|
||
|
||
watch(
|
||
[currentMethodology, currentStep, sortedCandidates],
|
||
async () => {
|
||
await nextTick()
|
||
renderVacancyChart()
|
||
renderTeamChart()
|
||
renderCandidateCharts()
|
||
},
|
||
{ flush: 'post' }
|
||
)
|
||
|
||
watch(
|
||
() => route?.query?.step,
|
||
() => {
|
||
syncStepFromRoute()
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => currentStep.value,
|
||
(value) => {
|
||
if (!route || !router) return
|
||
const step = String(value + 1)
|
||
if (route.query.step !== step) {
|
||
router.replace({ query: { ...route.query, step } })
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
vacancyChart?.dispose()
|
||
teamChart?.dispose()
|
||
candidateCharts.forEach((chart) => chart.dispose())
|
||
candidateCharts.clear()
|
||
if (granimInstance?.pause) granimInstance.pause()
|
||
})
|
||
|
||
const chatByStep = [
|
||
[
|
||
{ id: 'r1', from: 'ai', text: 'Опиши требования к вакансии. Заполни форму.', step: 0 },
|
||
{ id: 'r2', from: 'user', text: 'Ок, заполняю.' }
|
||
],
|
||
[
|
||
{ id: 'v1', from: 'ai', text: 'Запиши видео по сценарию и загрузи.', step: 1 },
|
||
{ id: 'v2', from: 'user', text: 'Ок, записываю.' }
|
||
],
|
||
[
|
||
{ id: 'f1', from: 'ai', text: 'AI сформировал отпечаток коллектива по видео.', step: 2 },
|
||
{ id: 'f2', from: 'user', text: 'Смотрю профиль.' }
|
||
],
|
||
[
|
||
{ id: 'p1', from: 'ai', text: 'Рекомендации готовы. Откликов нет.', step: 3 },
|
||
{ id: 'p2', from: 'user', text: 'Покажи топ.' }
|
||
],
|
||
[
|
||
{ id: 'm1', from: 'ai', text: 'Созданы мэтчи. Можно открыть чат или интро.', step: 4 },
|
||
{ id: 'm2', from: 'user', text: 'Открываю чат.' }
|
||
]
|
||
]
|
||
</script>
|