This commit is contained in:
xds
2026-03-16 14:46:20 +03:00
parent 00db55720c
commit de8c2472e2
45 changed files with 3714 additions and 140 deletions

View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import { onMounted, ref, computed, nextTick } from 'vue'
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 border-l-primary">
<template #content>
<div class="flex items-center justify-between">
<div>
<p class="text-surface-500 text-xs uppercase mb-1">Тренировка на сегодня</p>
<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 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>
</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>