445 lines
17 KiB
Vue
445 lines
17 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, ref, computed, nextTick } from 'vue'
|
||
import { RouterLink } from 'vue-router'
|
||
import { useCoachingStore } from '../stores/coaching'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import type { ChatMessage, TrainingPlan, ComplianceWeek, TodayWorkout } from '../types/models'
|
||
import Card from 'primevue/card'
|
||
import Button from 'primevue/button'
|
||
import InputText from 'primevue/inputtext'
|
||
import Tag from 'primevue/tag'
|
||
import ProgressBar from 'primevue/progressbar'
|
||
|
||
const coaching = useCoachingStore()
|
||
const auth = useAuthStore()
|
||
|
||
const activeTab = ref<'chat' | 'plan' | 'progress'>('chat')
|
||
const messageInput = ref('')
|
||
const messagesContainer = ref<HTMLElement | null>(null)
|
||
const compliance = ref<ComplianceWeek[]>([])
|
||
|
||
const messages = computed<ChatMessage[]>(() => coaching.currentChat?.messages || [])
|
||
const plan = computed<TrainingPlan | null>(() => coaching.activePlan)
|
||
const todayWorkout = computed<TodayWorkout | null>(() => coaching.todayWorkout)
|
||
const onboardingCompleted = ref(false)
|
||
const chatReady = ref(false)
|
||
|
||
function scrollToBottom() {
|
||
nextTick(() => {
|
||
if (messagesContainer.value) {
|
||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||
}
|
||
})
|
||
}
|
||
|
||
async function sendMessage() {
|
||
const text = messageInput.value.trim()
|
||
if (!text || !coaching.currentChat?.chat_id) return
|
||
|
||
messageInput.value = ''
|
||
await coaching.sendMessage(coaching.currentChat.chat_id, text)
|
||
scrollToBottom()
|
||
|
||
// Check if onboarding just completed
|
||
if (coaching.currentChat?.onboarding_completed && !onboardingCompleted.value) {
|
||
onboardingCompleted.value = true
|
||
await auth.fetchRider()
|
||
}
|
||
}
|
||
|
||
async function handleKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault()
|
||
await sendMessage()
|
||
}
|
||
}
|
||
|
||
async function startNewChat() {
|
||
await coaching.createChat()
|
||
scrollToBottom()
|
||
}
|
||
|
||
async function handleGeneratePlan() {
|
||
await coaching.generatePlan()
|
||
if (coaching.activePlan) {
|
||
activeTab.value = 'plan'
|
||
await loadCompliance()
|
||
}
|
||
}
|
||
|
||
async function handleAdjustPlan() {
|
||
await coaching.startPlanAdjustment()
|
||
activeTab.value = 'chat'
|
||
scrollToBottom()
|
||
}
|
||
|
||
async function loadCompliance() {
|
||
if (plan.value?.id) {
|
||
try {
|
||
compliance.value = await coaching.fetchCompliance(plan.value.id)
|
||
} catch { /* no compliance data */ }
|
||
}
|
||
}
|
||
|
||
function workoutTypeColor(type: string): string {
|
||
const colors: Record<string, string> = {
|
||
rest: 'secondary',
|
||
recovery: 'secondary',
|
||
endurance: 'info',
|
||
tempo: 'warn',
|
||
sweetspot: 'warn',
|
||
threshold: 'danger',
|
||
vo2max: 'danger',
|
||
sprint: 'contrast',
|
||
race: 'contrast',
|
||
}
|
||
return colors[type] || 'info'
|
||
}
|
||
|
||
function workoutTypeLabel(type: string): string {
|
||
const labels: Record<string, string> = {
|
||
rest: 'Отдых',
|
||
recovery: 'Восстановление',
|
||
endurance: 'Выносливость',
|
||
tempo: 'Темпо',
|
||
sweetspot: 'Sweet Spot',
|
||
threshold: 'Порог',
|
||
vo2max: 'VO2max',
|
||
sprint: 'Спринт',
|
||
race: 'Гонка',
|
||
}
|
||
return labels[type] || type
|
||
}
|
||
|
||
function dayLabel(day: string): string {
|
||
const labels: Record<string, string> = {
|
||
monday: 'Пн',
|
||
tuesday: 'Вт',
|
||
wednesday: 'Ср',
|
||
thursday: 'Чт',
|
||
friday: 'Пт',
|
||
saturday: 'Сб',
|
||
sunday: 'Вс',
|
||
}
|
||
return labels[day] || day
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// Check onboarding status
|
||
try {
|
||
const status = await coaching.getOnboardingStatus()
|
||
onboardingCompleted.value = status.onboarding_completed
|
||
|
||
if (!status.onboarding_completed) {
|
||
// Start or resume onboarding
|
||
await coaching.startOnboarding()
|
||
chatReady.value = true
|
||
scrollToBottom()
|
||
} else {
|
||
// Load active plan
|
||
await coaching.fetchActivePlan()
|
||
await coaching.fetchTodayWorkout()
|
||
|
||
if (plan.value) {
|
||
await loadCompliance()
|
||
}
|
||
|
||
// Start a general chat
|
||
await coaching.createChat()
|
||
chatReady.value = true
|
||
}
|
||
} catch {
|
||
chatReady.value = true
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<div class="flex items-center justify-between mb-6">
|
||
<h1 class="text-2xl font-semibold">AI Тренер</h1>
|
||
<div v-if="onboardingCompleted" class="flex gap-2">
|
||
<Button
|
||
:label="activeTab === 'chat' ? '' : 'Чат'"
|
||
icon="pi pi-comments"
|
||
:severity="activeTab === 'chat' ? 'primary' : 'secondary'"
|
||
:text="activeTab !== 'chat'"
|
||
size="small"
|
||
@click="activeTab = 'chat'"
|
||
/>
|
||
<Button
|
||
:label="activeTab === 'plan' ? '' : 'План'"
|
||
icon="pi pi-calendar"
|
||
:severity="activeTab === 'plan' ? 'primary' : 'secondary'"
|
||
:text="activeTab !== 'plan'"
|
||
size="small"
|
||
@click="activeTab = 'plan'"
|
||
/>
|
||
<Button
|
||
:label="activeTab === 'progress' ? '' : 'Прогресс'"
|
||
icon="pi pi-chart-bar"
|
||
:severity="activeTab === 'progress' ? 'primary' : 'secondary'"
|
||
:text="activeTab !== 'progress'"
|
||
size="small"
|
||
@click="activeTab = 'progress'"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Today's workout card -->
|
||
<Card v-if="todayWorkout && onboardingCompleted" :class="['mb-6 border-l-4', todayWorkout.completed ? 'border-l-green-500' : 'border-l-primary']">
|
||
<template #content>
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<p class="text-surface-500 text-xs uppercase">Тренировка на сегодня</p>
|
||
<Tag v-if="todayWorkout.completed" value="Выполнено" severity="success" class="text-xs" />
|
||
</div>
|
||
<p class="text-lg font-bold">{{ todayWorkout.title }}</p>
|
||
<p class="text-surface-600 text-sm mt-1">{{ todayWorkout.description }}</p>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<Tag :value="workoutTypeLabel(todayWorkout.workout_type)" :severity="workoutTypeColor(todayWorkout.workout_type)" />
|
||
<div v-if="todayWorkout.duration_minutes" class="text-right">
|
||
<p class="text-sm text-surface-500">{{ todayWorkout.duration_minutes }} мин</p>
|
||
<p v-if="todayWorkout.target_tss" class="text-xs text-surface-400">TSS {{ todayWorkout.target_tss }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</Card>
|
||
|
||
<!-- Onboarding banner -->
|
||
<Card v-if="!onboardingCompleted && chatReady" class="mb-4 bg-blue-50 border-blue-200">
|
||
<template #content>
|
||
<div class="flex items-center gap-3">
|
||
<i class="pi pi-info-circle text-blue-500 text-xl"></i>
|
||
<div>
|
||
<p class="font-semibold text-blue-800">Интервью с тренером</p>
|
||
<p class="text-blue-600 text-sm">Ответьте на несколько вопросов, чтобы AI тренер мог создать персональный план тренировок.</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</Card>
|
||
|
||
<!-- Chat Tab -->
|
||
<div v-if="activeTab === 'chat'" class="flex flex-col" style="height: calc(100vh - 300px); min-height: 400px;">
|
||
<!-- Chat actions -->
|
||
<div v-if="onboardingCompleted" class="flex gap-2 mb-3">
|
||
<Button label="Новый чат" icon="pi pi-plus" size="small" severity="secondary" outlined @click="startNewChat" />
|
||
<Button v-if="!plan" label="Сгенерировать план" icon="pi pi-bolt" size="small" severity="primary" :loading="coaching.loading" @click="handleGeneratePlan" />
|
||
<Button v-if="plan" label="Корректировать план" icon="pi pi-pencil" size="small" severity="warn" outlined @click="handleAdjustPlan" />
|
||
</div>
|
||
|
||
<!-- Messages -->
|
||
<div ref="messagesContainer" class="flex-1 overflow-y-auto space-y-3 p-4 bg-surface-50 rounded-xl border border-surface-200">
|
||
<div v-if="coaching.loading && messages.length === 0" class="flex items-center justify-center h-full text-surface-400">
|
||
<i class="pi pi-spin pi-spinner text-2xl mr-2"></i>
|
||
Загрузка...
|
||
</div>
|
||
|
||
<div v-for="(msg, i) in messages" :key="i" :class="['flex', msg.role === 'user' ? 'justify-end' : 'justify-start']">
|
||
<div
|
||
:class="[
|
||
'max-w-[80%] md:max-w-[70%] rounded-2xl px-4 py-3 text-sm leading-relaxed',
|
||
msg.role === 'user'
|
||
? 'bg-primary text-white rounded-br-md'
|
||
: 'bg-white border border-surface-200 text-surface-800 rounded-bl-md shadow-sm'
|
||
]"
|
||
>
|
||
<div v-if="msg.role === 'model'" class="flex items-center gap-2 mb-1">
|
||
<span class="text-xs font-semibold text-primary">VeloBrain</span>
|
||
</div>
|
||
<div class="whitespace-pre-wrap" v-html="formatMessage(msg.text)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="coaching.sending" class="flex justify-start">
|
||
<div class="bg-white border border-surface-200 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
|
||
<div class="flex items-center gap-2 text-surface-400 text-sm">
|
||
<i class="pi pi-spin pi-spinner"></i>
|
||
Думаю...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Input -->
|
||
<div class="flex gap-2 mt-3">
|
||
<InputText
|
||
v-model="messageInput"
|
||
:placeholder="onboardingCompleted ? 'Задайте вопрос тренеру...' : 'Ваш ответ...'"
|
||
class="flex-1"
|
||
:disabled="coaching.sending"
|
||
@keydown="handleKeydown"
|
||
/>
|
||
<Button
|
||
icon="pi pi-send"
|
||
:disabled="!messageInput.trim() || coaching.sending"
|
||
@click="sendMessage"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Plan Tab -->
|
||
<div v-if="activeTab === 'plan'">
|
||
<div v-if="!plan" class="text-center py-12">
|
||
<i class="pi pi-calendar text-4xl text-surface-300 mb-4"></i>
|
||
<p class="text-surface-500 mb-4">План тренировок ещё не создан</p>
|
||
<Button label="Сгенерировать план" icon="pi pi-bolt" :loading="coaching.loading" @click="handleGeneratePlan" />
|
||
</div>
|
||
|
||
<div v-else>
|
||
<!-- Plan header -->
|
||
<Card class="mb-6">
|
||
<template #content>
|
||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||
<div>
|
||
<h2 class="text-xl font-bold">{{ plan.goal }}</h2>
|
||
<p class="text-surface-500 text-sm mt-1">{{ plan.description }}</p>
|
||
<div class="flex items-center gap-3 mt-2">
|
||
<Tag :value="plan.phase || 'base'" severity="info" />
|
||
<span class="text-sm text-surface-500">
|
||
{{ new Date(plan.start_date).toLocaleDateString('ru-RU') }} —
|
||
{{ new Date(plan.end_date).toLocaleDateString('ru-RU') }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<Button label="Корректировать" icon="pi pi-pencil" size="small" severity="warn" outlined @click="handleAdjustPlan" />
|
||
<Button label="Новый план" icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="coaching.loading" @click="handleGeneratePlan" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</Card>
|
||
|
||
<!-- Weeks -->
|
||
<div v-for="week in plan.weeks" :key="week.week_number" class="mb-6">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="font-semibold text-lg">Неделя {{ week.week_number }}</h3>
|
||
<div class="flex items-center gap-3 text-sm text-surface-500">
|
||
<span>{{ week.focus }}</span>
|
||
<span v-if="week.target_tss">TSS {{ week.target_tss }}</span>
|
||
<span v-if="week.target_hours">{{ week.target_hours }}ч</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||
<Card v-for="day in week.days" :key="day.day" class="!shadow-none border border-surface-200">
|
||
<template #content>
|
||
<div class="flex items-center justify-between mb-2">
|
||
<span class="text-sm font-semibold text-surface-600">{{ dayLabel(day.day) }}</span>
|
||
<Tag :value="workoutTypeLabel(day.workout_type)" :severity="workoutTypeColor(day.workout_type)" class="text-xs" />
|
||
</div>
|
||
<p class="font-medium text-sm">{{ day.title }}</p>
|
||
<p v-if="day.description" class="text-xs text-surface-500 mt-1">{{ day.description }}</p>
|
||
<div v-if="day.duration_minutes > 0" class="flex items-center gap-3 mt-2 text-xs text-surface-400">
|
||
<span>{{ day.duration_minutes }} мин</span>
|
||
<span v-if="day.target_tss">TSS {{ day.target_tss }}</span>
|
||
<span v-if="day.target_if">IF {{ day.target_if }}</span>
|
||
</div>
|
||
</template>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Progress Tab -->
|
||
<div v-if="activeTab === 'progress'">
|
||
<div v-if="compliance.length === 0" class="text-center py-12">
|
||
<i class="pi pi-chart-bar text-4xl text-surface-300 mb-4"></i>
|
||
<p class="text-surface-500">Нет данных о прогрессе</p>
|
||
</div>
|
||
|
||
<div v-else class="space-y-4">
|
||
<Card v-for="week in compliance" :key="week.week_number">
|
||
<template #content>
|
||
<div class="flex flex-col gap-4">
|
||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-3 mb-2">
|
||
<h3 class="font-semibold">Неделя {{ week.week_number }}</h3>
|
||
<Tag
|
||
:value="week.status === 'upcoming' ? 'Впереди' : week.status === 'current' ? 'Текущая' : 'Завершена'"
|
||
:severity="week.status === 'upcoming' ? 'secondary' : week.status === 'current' ? 'info' : 'success'"
|
||
class="text-xs"
|
||
/>
|
||
<span class="text-sm text-surface-500">{{ week.focus }}</span>
|
||
</div>
|
||
|
||
<div class="mb-2">
|
||
<div class="flex items-center justify-between text-sm mb-1">
|
||
<span class="text-surface-500">Выполнение</span>
|
||
<span class="font-semibold">{{ week.adherence_pct }}%</span>
|
||
</div>
|
||
<ProgressBar
|
||
:value="week.adherence_pct"
|
||
:showValue="false"
|
||
style="height: 8px"
|
||
:class="week.adherence_pct >= 80 ? '' : week.adherence_pct >= 50 ? '[&_.p-progressbar-value]:!bg-amber-500' : '[&_.p-progressbar-value]:!bg-red-500'"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-3 gap-4 text-center text-sm">
|
||
<div>
|
||
<p class="text-surface-500 text-xs">Тренировки</p>
|
||
<p class="font-semibold">{{ week.actual_rides }}/{{ week.planned_rides }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-surface-500 text-xs">TSS</p>
|
||
<p class="font-semibold">{{ week.actual_tss }}/{{ week.planned_tss }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-surface-500 text-xs">Часы</p>
|
||
<p class="font-semibold">{{ week.actual_hours }}/{{ week.planned_hours }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Per-day breakdown -->
|
||
<div v-if="week.days?.length" class="flex flex-wrap gap-2">
|
||
<div
|
||
v-for="d in week.days"
|
||
:key="d.day"
|
||
:class="[
|
||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs border',
|
||
d.completed
|
||
? 'bg-green-50 border-green-200 text-green-700'
|
||
: d.workout_type === 'rest'
|
||
? 'bg-surface-50 border-surface-200 text-surface-400'
|
||
: 'bg-white border-surface-200 text-surface-600'
|
||
]"
|
||
>
|
||
<i :class="['pi text-xs', d.completed ? 'pi-check-circle text-green-500' : d.workout_type === 'rest' ? 'pi-minus text-surface-300' : 'pi-circle text-surface-300']"></i>
|
||
<span class="font-semibold">{{ dayLabel(d.day) }}</span>
|
||
<span>{{ d.planned }}</span>
|
||
<RouterLink
|
||
v-if="d.activity_id"
|
||
:to="{ name: 'activity-detail', params: { id: d.activity_id } }"
|
||
class="text-primary hover:underline ml-1"
|
||
@click.stop
|
||
>
|
||
<i class="pi pi-external-link text-xs"></i>
|
||
</RouterLink>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
function formatMessage(text: string): string {
|
||
// Strip [ONBOARDING_COMPLETE] and JSON blocks from display
|
||
let cleaned = text.replace(/\[ONBOARDING_COMPLETE\]/g, '')
|
||
cleaned = cleaned.replace(/```json[\s\S]*?```/g, '')
|
||
cleaned = cleaned.replace(/\[PLAN_ADJUSTED\]/g, '')
|
||
return cleaned.trim()
|
||
}
|
||
</script>
|