fix
This commit is contained in:
410
frontend/src/views/CoachView.vue
Normal file
410
frontend/src/views/CoachView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user