5.0.9: Ожидание бэкенда при завершении теста
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "5.0.8",
|
"version": "5.0.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useAuth } from './auth/AuthContext'
|
import { useAuth } from './auth/AuthContext'
|
||||||
import LoadingError from './LoadingError'
|
import LoadingError from './LoadingError'
|
||||||
import './TestWords.css'
|
import './TestWords.css'
|
||||||
@@ -26,11 +26,14 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
|
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
|
||||||
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
|
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
|
||||||
|
const [isClosing, setIsClosing] = useState(false) // Ожидание завершения сохранения при нажатии «Закончить»
|
||||||
|
|
||||||
const isFinishingRef = useRef(false)
|
const isFinishingRef = useRef(false)
|
||||||
const wordStatsRef = useRef({})
|
const wordStatsRef = useRef({})
|
||||||
const processingRef = useRef(false)
|
const processingRef = useRef(false)
|
||||||
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
|
const cardsShownRef = useRef(0) // Синхронный счётчик для избежания race condition
|
||||||
|
const saveProgressPromiseRef = useRef(null) // Промис сохранения прогресса (null = ещё не запущено)
|
||||||
|
const savePayloadRef = useRef(null) // Данные для сохранения при нажатии «Закончить» (если сохранение ещё не запускали)
|
||||||
|
|
||||||
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
// Функция равномерного распределения слов в пуле с гарантией максимального расстояния между одинаковыми словами
|
||||||
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
// excludeFirstWordId - ID слова, которое не должно быть первым в пуле (только что показанная карточка)
|
||||||
@@ -221,6 +224,8 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
|
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
|
||||||
isFinishingRef.current = false
|
isFinishingRef.current = false
|
||||||
processingRef.current = false
|
processingRef.current = false
|
||||||
|
saveProgressPromiseRef.current = null
|
||||||
|
savePayloadRef.current = null
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const loadWords = async () => {
|
const loadWords = async () => {
|
||||||
@@ -307,89 +312,80 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Завершение теста
|
// Завершение теста: показываем результаты сразу, сохранение на бэкенде идёт в фоне (промис в ref для ожидания в handleFinish)
|
||||||
const finishTest = async () => {
|
const finishTest = () => {
|
||||||
if (isFinishingRef.current) return
|
if (isFinishingRef.current) return
|
||||||
isFinishingRef.current = true
|
isFinishingRef.current = true
|
||||||
|
|
||||||
// Сразу показываем экран результатов, чтобы предотвратить показ новых карточек
|
const currentStats = wordStatsRef.current
|
||||||
setShowResults(true)
|
const updates = words.map(word => {
|
||||||
|
const stats = currentStats[word.id] || {
|
||||||
// Отправляем статистику на бэкенд
|
success: word.success || 0,
|
||||||
try {
|
failure: word.failure || 0,
|
||||||
// Получаем актуальные данные из состояния
|
lastSuccessAt: word.last_success_at || null,
|
||||||
const currentStats = wordStatsRef.current
|
lastFailureAt: word.last_failure_at || null
|
||||||
|
|
||||||
// Отправляем все слова, которые были в тесте, с их текущими значениями
|
|
||||||
// Бэкенд сам обновит только измененные поля
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
const requestBody = { words: updates }
|
id: word.id,
|
||||||
if (configId !== null) {
|
success: stats.success || 0,
|
||||||
requestBody.config_id = configId
|
failure: stats.failure || 0,
|
||||||
|
last_success_at: stats.lastSuccessAt || null,
|
||||||
|
last_failure_at: stats.lastFailureAt || null
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
console.log('Sending test progress to backend:', {
|
if (updates.length === 0) {
|
||||||
wordsCount: updates.length,
|
saveProgressPromiseRef.current = Promise.resolve()
|
||||||
configId: configId,
|
savePayloadRef.current = null
|
||||||
requestBody
|
setShowResults(true)
|
||||||
})
|
return
|
||||||
|
|
||||||
const response = await authFetch(`${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)
|
|
||||||
|
|
||||||
// Если есть taskId, выполняем задачу
|
|
||||||
if (taskId) {
|
|
||||||
try {
|
|
||||||
const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (completeResponse.ok) {
|
|
||||||
console.log('Task completed successfully')
|
|
||||||
} else {
|
|
||||||
console.error('Failed to complete task:', await completeResponse.text())
|
|
||||||
}
|
|
||||||
} catch (taskErr) {
|
|
||||||
console.error('Failed to complete task:', taskErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to save progress:', err)
|
|
||||||
// Можно показать уведомление пользователю, но не блокируем показ результатов
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestBody = { words: updates }
|
||||||
|
if (configId !== null) {
|
||||||
|
requestBody.config_id = configId
|
||||||
|
}
|
||||||
|
|
||||||
|
savePayloadRef.current = { requestBody, configId, taskId }
|
||||||
|
|
||||||
|
const doSave = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`${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}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await response.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (taskId) {
|
||||||
|
try {
|
||||||
|
const completeResponse = await authFetch(`${API_URL}/tasks/${taskId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!completeResponse.ok) {
|
||||||
|
console.error('Failed to complete task:', await completeResponse.text())
|
||||||
|
}
|
||||||
|
} catch (taskErr) {
|
||||||
|
console.error('Failed to complete task:', taskErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save progress:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = doSave()
|
||||||
|
saveProgressPromiseRef.current = promise
|
||||||
|
setShowResults(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Берём карточку из пула (getAndDelete) и показываем её
|
// Берём карточку из пула (getAndDelete) и показываем её
|
||||||
@@ -566,8 +562,46 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
showNextCard()
|
showNextCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFinish = () => {
|
const runSaveFromPayload = useCallback(async (payload) => {
|
||||||
onNavigate?.('tasks')
|
const { requestBody, taskId: payloadTaskId } = payload
|
||||||
|
const progressRes = await authFetch(`${API_URL}/test/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
if (!progressRes.ok) {
|
||||||
|
const text = await progressRes.text()
|
||||||
|
throw new Error(`Progress save failed: ${progressRes.status} ${text}`)
|
||||||
|
}
|
||||||
|
if (payloadTaskId) {
|
||||||
|
const completeRes = await authFetch(`${API_URL}/tasks/${payloadTaskId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
if (!completeRes.ok) {
|
||||||
|
const text = await completeRes.text()
|
||||||
|
throw new Error(`Task complete failed: ${completeRes.status} ${text}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [authFetch])
|
||||||
|
|
||||||
|
const handleFinish = async () => {
|
||||||
|
if (isClosing) return
|
||||||
|
setIsClosing(true)
|
||||||
|
try {
|
||||||
|
let promise = saveProgressPromiseRef.current
|
||||||
|
if (promise == null && savePayloadRef.current) {
|
||||||
|
promise = runSaveFromPayload(savePayloadRef.current)
|
||||||
|
saveProgressPromiseRef.current = promise
|
||||||
|
}
|
||||||
|
await (promise ?? Promise.resolve())
|
||||||
|
onNavigate?.('tasks')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save before close:', err)
|
||||||
|
} finally {
|
||||||
|
setIsClosing(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRandomSide = (word) => {
|
const getRandomSide = (word) => {
|
||||||
@@ -630,8 +664,12 @@ function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialC
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="results-actions">
|
<div className="results-actions">
|
||||||
<button className="test-finish-button" onClick={handleFinish}>
|
<button
|
||||||
Закончить
|
className="test-finish-button"
|
||||||
|
onClick={handleFinish}
|
||||||
|
disabled={isClosing}
|
||||||
|
>
|
||||||
|
{isClosing ? 'Завершение…' : 'Закончить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user