Initial commit
This commit is contained in:
490
play-life-web/src/components/TestWords.jsx
Normal file
490
play-life-web/src/components/TestWords.jsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import './TestWords.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
const DEFAULT_TEST_WORD_COUNT = 10
|
||||
|
||||
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
|
||||
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
|
||||
const configId = initialConfigId || null
|
||||
const maxCards = initialMaxCards || null
|
||||
|
||||
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
|
||||
const [testWords, setTestWords] = useState([]) // Пул слов для показа
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [flippedCards, setFlippedCards] = useState(new Set())
|
||||
const [wordStats, setWordStats] = useState({}) // Локальная статистика
|
||||
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
|
||||
const [totalAnswers, setTotalAnswers] = useState(0) // Кол-во полученных ответов
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
|
||||
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
|
||||
|
||||
const isFinishingRef = useRef(false)
|
||||
const wordStatsRef = useRef({})
|
||||
const processingRef = useRef(false)
|
||||
|
||||
// Загрузка слов при монтировании
|
||||
useEffect(() => {
|
||||
setWords([])
|
||||
setTestWords([])
|
||||
setCurrentIndex(0)
|
||||
setFlippedCards(new Set())
|
||||
setWordStats({})
|
||||
wordStatsRef.current = {}
|
||||
setCardsShown(0)
|
||||
setTotalAnswers(0)
|
||||
setError('')
|
||||
setShowPreview(false) // Сбрасываем экран предпросмотра
|
||||
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
|
||||
isFinishingRef.current = false
|
||||
processingRef.current = false
|
||||
setLoading(true)
|
||||
|
||||
const loadWords = async () => {
|
||||
try {
|
||||
if (configId === null) {
|
||||
throw new Error('config_id обязателен для запуска теста')
|
||||
}
|
||||
const url = `${API_URL}/test/words?config_id=${configId}`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке слов')
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error('Недостаточно слов для теста')
|
||||
}
|
||||
|
||||
// Инициализируем статистику из данных бэкенда
|
||||
const stats = {}
|
||||
data.forEach(word => {
|
||||
stats[word.id] = {
|
||||
success: word.success || 0,
|
||||
failure: word.failure || 0,
|
||||
lastSuccessAt: word.last_success_at || null,
|
||||
lastFailureAt: word.last_failure_at || null
|
||||
}
|
||||
})
|
||||
|
||||
setWords(data)
|
||||
|
||||
// Формируем пул слов: каждое слово добавляется n раз, затем пул перемешивается
|
||||
// n = max(1, floor(0.7 * maxCards / количество_слов))
|
||||
const wordsCount = data.length
|
||||
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
|
||||
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
|
||||
|
||||
// Создаем пул, где каждое слово повторяется n раз
|
||||
const wordPool = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
wordPool.push(...data)
|
||||
}
|
||||
|
||||
// Перемешиваем пул случайным образом
|
||||
for (let i = wordPool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]]
|
||||
}
|
||||
|
||||
setTestWords(wordPool)
|
||||
setWordStats(stats)
|
||||
wordStatsRef.current = stats
|
||||
|
||||
// Показываем экран предпросмотра
|
||||
setShowPreview(true)
|
||||
|
||||
// Показываем первую карточку и увеличиваем левый счётчик (будет использовано после начала теста)
|
||||
setCardsShown(0)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadWords()
|
||||
}, [wordCount, configId])
|
||||
|
||||
const getCurrentWord = () => {
|
||||
if (currentIndex < testWords.length && testWords.length > 0) {
|
||||
return testWords[currentIndex]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards)
|
||||
const getRightCounter = () => {
|
||||
const total = totalAnswers + testWords.length
|
||||
if (maxCards !== null && maxCards > 0) {
|
||||
return Math.min(total, maxCards)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const handleCardFlip = (wordId) => {
|
||||
setFlippedCards(prev => new Set(prev).add(wordId))
|
||||
}
|
||||
|
||||
// Завершение теста
|
||||
const finishTest = async () => {
|
||||
if (isFinishingRef.current) return
|
||||
isFinishingRef.current = true
|
||||
|
||||
// Сразу показываем экран результатов, чтобы предотвратить показ новых карточек
|
||||
setShowResults(true)
|
||||
|
||||
// Отправляем статистику на бэкенд
|
||||
try {
|
||||
// Получаем актуальные данные из состояния
|
||||
const currentStats = wordStatsRef.current
|
||||
|
||||
// Отправляем все слова, которые были в тесте, с их текущими значениями
|
||||
// Бэкенд сам обновит только измененные поля
|
||||
const updates = words.map(word => {
|
||||
const stats = currentStats[word.id] || {
|
||||
success: word.success || 0,
|
||||
failure: word.failure || 0,
|
||||
lastSuccessAt: word.last_success_at || null,
|
||||
lastFailureAt: word.last_failure_at || null
|
||||
}
|
||||
return {
|
||||
id: word.id,
|
||||
success: stats.success || 0,
|
||||
failure: stats.failure || 0,
|
||||
last_success_at: stats.lastSuccessAt || null,
|
||||
last_failure_at: stats.lastFailureAt || null
|
||||
}
|
||||
})
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log('No words to send - empty test')
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = { words: updates }
|
||||
if (configId !== null) {
|
||||
requestBody.config_id = configId
|
||||
}
|
||||
|
||||
console.log('Sending test progress to backend:', {
|
||||
wordsCount: updates.length,
|
||||
configId: configId,
|
||||
requestBody
|
||||
})
|
||||
|
||||
const response = await fetch(`${API_URL}/test/progress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Server responded with status ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const responseData = await response.json().catch(() => ({}))
|
||||
console.log('Test progress saved successfully:', responseData)
|
||||
} catch (err) {
|
||||
console.error('Failed to save progress:', err)
|
||||
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка условий завершения и показ следующей карточки
|
||||
const showNextCardOrFinish = (newTestWords, currentCardsShown) => {
|
||||
// Проверяем, не завершился ли тест (на случай если finishTest уже был вызван)
|
||||
if (isFinishingRef.current || showResults) {
|
||||
return
|
||||
}
|
||||
|
||||
// Условие 1: Достигли максимума карточек
|
||||
if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) {
|
||||
finishTest()
|
||||
return
|
||||
}
|
||||
|
||||
// Условие 2: Пул слов пуст
|
||||
if (newTestWords.length === 0) {
|
||||
finishTest()
|
||||
return
|
||||
}
|
||||
|
||||
// Показываем следующую карточку и увеличиваем левый счётчик
|
||||
// Но сначала проверяем, не достигнем ли мы максимума после увеличения
|
||||
const nextCardsShown = currentCardsShown + 1
|
||||
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
|
||||
finishTest()
|
||||
return
|
||||
}
|
||||
|
||||
setCardsShown(nextCardsShown)
|
||||
}
|
||||
|
||||
const handleSuccess = (wordId) => {
|
||||
if (processingRef.current || isFinishingRef.current || showResults) return
|
||||
processingRef.current = true
|
||||
|
||||
const word = words.find(w => w.id === wordId)
|
||||
if (!word) {
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Обновляем статистику: success + 1, lastSuccessAt = now
|
||||
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
|
||||
const updatedStats = {
|
||||
...wordStatsRef.current,
|
||||
[wordId]: {
|
||||
success: (currentWordStats.success || 0) + 1,
|
||||
failure: currentWordStats.failure || 0,
|
||||
lastSuccessAt: now,
|
||||
lastFailureAt: currentWordStats.lastFailureAt || null
|
||||
}
|
||||
}
|
||||
wordStatsRef.current = updatedStats
|
||||
setWordStats(updatedStats)
|
||||
|
||||
// Увеличиваем счётчик ответов
|
||||
const newTotalAnswers = totalAnswers + 1
|
||||
setTotalAnswers(newTotalAnswers)
|
||||
|
||||
// Убираем только один экземпляр слова из пула (по текущему индексу)
|
||||
const newTestWords = [...testWords]
|
||||
newTestWords.splice(currentIndex, 1)
|
||||
|
||||
// Обновляем индекс: если удалили последний элемент, переходим к предыдущему
|
||||
let newIndex = currentIndex
|
||||
if (newTestWords.length === 0) {
|
||||
newIndex = 0
|
||||
} else if (currentIndex >= newTestWords.length) {
|
||||
newIndex = newTestWords.length - 1
|
||||
}
|
||||
|
||||
// Обновляем состояние
|
||||
setTestWords(newTestWords)
|
||||
setCurrentIndex(newIndex)
|
||||
setFlippedCards(new Set())
|
||||
|
||||
// Проверяем условия завершения или показываем следующую карточку
|
||||
showNextCardOrFinish(newTestWords, cardsShown)
|
||||
|
||||
// Если тест завершился, не сбрасываем processingRef
|
||||
if (isFinishingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
const handleFailure = (wordId) => {
|
||||
if (processingRef.current || isFinishingRef.current || showResults) return
|
||||
processingRef.current = true
|
||||
|
||||
const word = words.find(w => w.id === wordId)
|
||||
if (!word) {
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Обновляем статистику: failure + 1, lastFailureAt = now
|
||||
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
|
||||
const updatedStats = {
|
||||
...wordStatsRef.current,
|
||||
[wordId]: {
|
||||
success: currentWordStats.success || 0,
|
||||
failure: (currentWordStats.failure || 0) + 1,
|
||||
lastSuccessAt: currentWordStats.lastSuccessAt || null,
|
||||
lastFailureAt: now
|
||||
}
|
||||
}
|
||||
wordStatsRef.current = updatedStats
|
||||
setWordStats(updatedStats)
|
||||
|
||||
// Увеличиваем счётчик ответов
|
||||
const newTotalAnswers = totalAnswers + 1
|
||||
setTotalAnswers(newTotalAnswers)
|
||||
|
||||
// Слово остаётся в пуле, переходим к следующему
|
||||
let newIndex = currentIndex + 1
|
||||
if (newIndex >= testWords.length) {
|
||||
newIndex = 0
|
||||
}
|
||||
|
||||
setCurrentIndex(newIndex)
|
||||
setFlippedCards(new Set())
|
||||
|
||||
// Проверяем условия завершения или показываем следующую карточку
|
||||
// При failure пул не изменяется
|
||||
showNextCardOrFinish(testWords, cardsShown)
|
||||
|
||||
// Если тест завершился, не сбрасываем processingRef
|
||||
if (isFinishingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onNavigate?.('test-config')
|
||||
}
|
||||
|
||||
const handleStartTest = () => {
|
||||
setShowPreview(false)
|
||||
// Показываем первую карточку и увеличиваем левый счётчик
|
||||
setCardsShown(1)
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
onNavigate?.('test-config')
|
||||
}
|
||||
|
||||
const getRandomSide = (word) => {
|
||||
return word.id % 2 === 0 ? 'word' : 'translation'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="test-container test-container-fullscreen">
|
||||
<button className="test-close-x-button" onClick={handleClose}>
|
||||
✕
|
||||
</button>
|
||||
{showPreview ? (
|
||||
<div className="test-preview">
|
||||
<div className="preview-stats">
|
||||
{words.map((word) => {
|
||||
const stats = wordStats[word.id] || { success: 0, failure: 0 }
|
||||
return (
|
||||
<div key={word.id} className="preview-item">
|
||||
<div className="preview-word-content">
|
||||
<div className="preview-word-header">
|
||||
<h3 className="preview-word">{word.name}</h3>
|
||||
</div>
|
||||
<div className="preview-translation">{word.translation}</div>
|
||||
</div>
|
||||
<div className="preview-stats-numbers">
|
||||
<span className="preview-stat-success">{stats.success}</span>
|
||||
<span className="preview-stat-separator"> | </span>
|
||||
<span className="preview-stat-failure">{stats.failure}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="preview-actions">
|
||||
<button className="test-start-button" onClick={handleStartTest}>
|
||||
Начать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : showResults ? (
|
||||
<div className="test-results">
|
||||
<div className="results-stats">
|
||||
{words.map((word) => {
|
||||
const stats = wordStats[word.id] || { success: 0, failure: 0 }
|
||||
return (
|
||||
<div key={word.id} className="result-item">
|
||||
<div className="result-word-content">
|
||||
<div className="result-word-header">
|
||||
<h3 className="result-word">{word.name}</h3>
|
||||
</div>
|
||||
<div className="result-translation">{word.translation}</div>
|
||||
</div>
|
||||
<div className="result-stats">
|
||||
<span className="result-stat-success">{stats.success}</span>
|
||||
<span className="result-stat-separator"> | </span>
|
||||
<span className="result-stat-failure">{stats.failure}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="results-actions">
|
||||
<button className="test-finish-button" onClick={handleFinish}>
|
||||
Закончить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="test-screen">
|
||||
{loading && (
|
||||
<div className="test-loading">Загрузка слов...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="test-error">{error}</div>
|
||||
)}
|
||||
{!loading && !error && !isFinishingRef.current && getCurrentWord() && (() => {
|
||||
const word = getCurrentWord()
|
||||
const isFlipped = flippedCards.has(word.id)
|
||||
const showSide = getRandomSide(word)
|
||||
|
||||
return (
|
||||
<div className="test-card-container" key={word.id}>
|
||||
<div
|
||||
className={`test-card ${isFlipped ? 'flipped' : ''}`}
|
||||
onClick={() => !isFlipped && handleCardFlip(word.id)}
|
||||
>
|
||||
<div className="test-card-front">
|
||||
<div className="test-card-content">
|
||||
{showSide === 'word' ? (
|
||||
<div className="test-word">{word.name}</div>
|
||||
) : (
|
||||
<div className="test-translation">{word.translation}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="test-card-back">
|
||||
<div className="test-card-content">
|
||||
{showSide === 'word' ? (
|
||||
<div className="test-translation">{word.translation}</div>
|
||||
) : (
|
||||
<div className="test-word">{word.name}</div>
|
||||
)}
|
||||
<div className="test-card-actions">
|
||||
<button
|
||||
className="test-action-button success-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSuccess(word.id)
|
||||
}}
|
||||
>
|
||||
✓ Знаю
|
||||
</button>
|
||||
<button
|
||||
className="test-action-button failure-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleFailure(word.id)
|
||||
}}
|
||||
>
|
||||
✗ Не знаю
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{!loading && !error && (
|
||||
<div className="test-progress">
|
||||
<div className="progress-text">
|
||||
{cardsShown} / {getRightCounter()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestWords
|
||||
Reference in New Issue
Block a user