courceNameSuggestion feature

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-04-06 17:44:26 +03:00
parent 5debb1ebe9
commit 386d2b409d
5 changed files with 166 additions and 29 deletions

View File

@ -44,6 +44,11 @@ module.exports = {
value: '',
key: 'courses.statistics',
},
'courceNameSuggestion': {
on: true,
value: '',
key: 'courceNameSuggestion',
},
},
},
config: {

View File

@ -73,6 +73,14 @@ export const api = createApi({
query: (courseId) => `/lesson/${courseId}/ai/generate-lessons`,
}),
generateLessonName: builder.mutation<BaseResponse<{ name: string }[]>, { courseId: string, name: string }>({
query: ({ courseId, name }) => ({
url: `/lesson/${courseId}/ai/generate-lesson-name`,
method: 'POST',
body: { name },
}),
}),
createLesson: builder.mutation<
BaseResponse<Lesson>,
Partial<Lesson> & Pick<Lesson, 'name' | 'date'> & { courseId: string }

View File

@ -119,6 +119,7 @@ export const Item: React.FC<ItemProps> = ({
onCancel={() => {
setEdit(false)
}}
courseId={courseId}
lesson={{ _id: id, id, name, date }}
title={t('journal.pl.lesson.editTitle')}
nameButton={t('journal.pl.save')}

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState, useRef, useCallback } from 'react'
import { getFeatures } from '@brojs/cli'
import { useForm, Controller } from 'react-hook-form'
import {
Box,
@ -29,7 +30,11 @@ import {
Wrap,
WrapItem,
IconButton,
Center
Center,
InputGroup,
InputRightElement,
List,
ListItem
} from '@chakra-ui/react'
import { AddIcon, CheckIcon, WarningIcon, RepeatIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
@ -40,6 +45,9 @@ import { formatDate } from '../../../utils/dayjs-config'
import { dateToCalendarFormat } from '../../../utils/time'
import { Lesson } from '../../../__data__/model'
import { ErrorSpan } from '../style'
import { api } from '../../../__data__/api/api'
const courceNameSuggestion = getFeatures('journal')['courceNameSuggestion']
interface NewLessonForm {
name: string
@ -49,6 +57,7 @@ interface NewLessonForm {
interface LessonFormProps {
lesson?: Partial<Lesson> | any // Разрешаем передавать как Lesson, так и AI-сгенерированный урок
courseId: string
isLoading: boolean
onCancel: () => void
onSubmit: (lesson: Lesson) => void
@ -65,6 +74,7 @@ interface LessonFormProps {
export const LessonForm = ({
lesson,
courseId,
isLoading,
onCancel,
onSubmit,
@ -85,6 +95,72 @@ export const LessonForm = ({
const suggestionHoverBgColor = useColorModeValue('blue.100', 'blue.800')
const borderColor = useColorModeValue('blue.200', 'blue.700')
const textSecondaryColor = useColorModeValue('gray.600', 'gray.400')
const [suggestions, setSuggestions] = useState<string[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const suggestionsContainerRef = useRef<HTMLDivElement>(null)
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const [generateLessonName, {
data: generateLessonNameData,
isLoading: isLoadingGenerateLessonName,
error: errorGenerateLessonName,
isSuccess: isSuccessGenerateLessonName
}] = api.useGenerateLessonNameMutation()
// Функция debounce для запросов
const debouncedGenerateName = useCallback((value: string) => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
debounceTimeoutRef.current = setTimeout(() => {
if (value.length > 2) {
generateLessonName({ courseId: courseId, name: value })
} else {
setSuggestions([])
}
}, 300) // 300ms задержка
}, [courseId, generateLessonName])
useEffect(() => {
if (isSuccessGenerateLessonName) {
setSuggestions(generateLessonNameData.body.map(suggestion => suggestion.name))
}
}, [isSuccessGenerateLessonName])
// Эффект для корректного позиционирования списка подсказок
useEffect(() => {
const positionSuggestions = () => {
if (inputRef.current && suggestionsContainerRef.current && showSuggestions) {
const inputRect = inputRef.current.getBoundingClientRect()
suggestionsContainerRef.current.style.top = `${inputRect.bottom + window.scrollY}px`
suggestionsContainerRef.current.style.left = `${inputRect.left + window.scrollX}px`
suggestionsContainerRef.current.style.width = `${inputRect.width}px`
}
}
positionSuggestions()
// Обновляем позицию при скролле или изменении размера окна
window.addEventListener('scroll', positionSuggestions)
window.addEventListener('resize', positionSuggestions)
return () => {
window.removeEventListener('scroll', positionSuggestions)
window.removeEventListener('resize', positionSuggestions)
}
}, [showSuggestions, suggestions.length])
// Эффект для очистки timeout при размонтировании компонента
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
}
}, [])
const getNearestTimeSlot = () => {
const now = new Date();
@ -149,27 +225,6 @@ export const LessonForm = ({
return slots;
};
const getNextTimeSlots = (date: string, count: number = 3) => {
const currentDate = new Date();
const selectedDate = new Date(date);
const isToday = selectedDate.toDateString() === currentDate.toDateString();
if (!isToday) return [];
const currentMinutes = currentDate.getHours() * 60 + currentDate.getMinutes();
const slots = generateTimeSlots();
return slots
.map(slot => {
const [hours, minutes] = slot.split(':').map(Number);
const slotMinutes = hours * 60 + minutes;
return { slot, minutes: slotMinutes };
})
.filter(({ minutes }) => minutes > currentMinutes)
.slice(0, count)
.map(({ slot }) => slot);
};
const timeGroups = {
[`${t('journal.pl.days.morning')} (8-12)`]: generateTimeSlots().filter(slot => {
const hour = parseInt(slot.split(':')[0]);
@ -490,12 +545,30 @@ export const LessonForm = ({
render={({ field }) => (
<FormControl isRequired isInvalid={Boolean(errors.name)}>
<FormLabel>{t('journal.pl.lesson.form.title')}</FormLabel>
<Box position="relative">
<InputGroup>
<Input
{...field}
ref={inputRef}
required={false}
placeholder={t('journal.pl.lesson.form.namePlaceholder')}
size="md"
onChange={(e) => {
const value = e.target.value
setInputValue(value)
field.onChange(value)
if (value.length > 2 && courceNameSuggestion) {
setShowSuggestions(true)
debouncedGenerateName(value)
} else {
setSuggestions([])
}
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
/>
</InputGroup>
</Box>
{errors.name && (
<FormErrorMessage>{errors.name.message}</FormErrorMessage>
)}
@ -610,6 +683,39 @@ export const LessonForm = ({
</>
)}
</CardBody>
{/* Выпадающий список подсказок (размещаем вне стандартного потока документа) */}
{suggestions.length > 0 && showSuggestions && (
<Box
position="fixed"
ref={suggestionsContainerRef}
bg={useColorModeValue('white', 'gray.800')}
borderRadius="md"
boxShadow="md"
zIndex={9999}
maxH="200px"
overflowY="auto"
>
<List>
{suggestions.map((suggestion, index) => (
<ListItem
key={index}
p={2}
cursor="pointer"
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
onClick={() => {
setValue('name', suggestion)
setInputValue(suggestion)
setSuggestions([])
setShowSuggestions(false)
}}
>
{suggestion}
</ListItem>
))}
</List>
</Box>
)}
</Card>
)
}

View File

@ -131,6 +131,23 @@ router.get('/lesson/:courseId/ai/generate-lessons', timer(3000), (req, res) => {
res.send(modifiedData);
})
router.post('/lesson/:courseId/ai/generate-lesson-name', timer(3000), (req, res) => {
res.send({
"success": true,
"body": [
{
"name": "Основы CSS"
},
{
"name": "CSS селекторы и свойства"
},
{
"name": "Анимации и переходы на CSS"
}
]
});
})
router.post('/lesson', (req, res) => {
const baseData = readJsonFile('../mocks/lessons/create/success.json');