Bug fixes

This commit is contained in:
2026-01-08 06:51:15 +07:00
parent 4488a13808
commit 2874b64481
12 changed files with 434 additions and 54 deletions

View File

@@ -76,6 +76,14 @@ export const adminApi = {
await client.post(`/admin/marathons/${id}/force-finish`)
},
certifyMarathon: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/certify`)
},
revokeCertification: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/revoke-certification`)
},
// Stats
getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats')

View File

@@ -1,13 +1,14 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment } from '@/types'
import { marathonsApi, wheelApi, gamesApi, eventsApi, assignmentsApi, shopApi } from '@/api'
import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, GameChoiceChallenges, ReturnedAssignment, ConsumablesStatus, ConsumableType } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download, Shield, RefreshCw, SkipForward, Package } from 'lucide-react'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { useShopStore } from '@/store/shop'
const MAX_IMAGE_SIZE = 15 * 1024 * 1024
const MAX_VIDEO_SIZE = 30 * 1024 * 1024
@@ -55,6 +56,10 @@ export function PlayPage() {
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
// Consumables
const [consumablesStatus, setConsumablesStatus] = useState<ConsumablesStatus | null>(null)
const [isUsingConsumable, setIsUsingConsumable] = useState<ConsumableType | null>(null)
// Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
@@ -177,13 +182,14 @@ export function PlayPage() {
const loadData = async () => {
if (!id) return
try {
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData, consumablesData] = await Promise.all([
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.getAvailableGames(parseInt(id)),
eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)),
shopApi.getConsumablesStatus(parseInt(id)).catch(() => null),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
@@ -191,6 +197,7 @@ export function PlayPage() {
setActiveEvent(eventData)
setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData)
setConsumablesStatus(consumablesData)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
@@ -255,6 +262,8 @@ export function PlayPage() {
setProofUrl('')
setComment('')
await loadData()
// Refresh coins balance
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
@@ -464,6 +473,85 @@ export function PlayPage() {
}
}
// Consumable handlers
const handleUseSkip = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('skip')
try {
await shopApi.useConsumable({
item_code: 'skip',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание пропущено без штрафа!')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseReroll = async () => {
if (!currentAssignment || !id) return
setIsUsingConsumable('reroll')
try {
await shopApi.useConsumable({
item_code: 'reroll',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание отменено! Можно крутить заново.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Reroll')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseShield = async () => {
if (!id) return
setIsUsingConsumable('shield')
try {
await shopApi.useConsumable({
item_code: 'shield',
marathon_id: parseInt(id),
})
toast.success('Shield активирован! Следующий пропуск будет бесплатным.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Shield')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseBoost = async () => {
if (!id) return
setIsUsingConsumable('boost')
try {
await shopApi.useConsumable({
item_code: 'boost',
marathon_id: parseInt(id),
})
toast.success('Boost активирован! x1.5 очков за следующее выполнение.')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось активировать Boost')
} finally {
setIsUsingConsumable(null)
}
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
@@ -608,6 +696,135 @@ export function PlayPage() {
</GlassCard>
)}
{/* Consumables Panel */}
{consumablesStatus && marathon?.allow_consumables && (
<GlassCard className="mb-6 border-purple-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<Package className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-purple-400">Расходники</h3>
<p className="text-sm text-gray-400">Используйте для облегчения задания</p>
</div>
</div>
{/* Active effects */}
{(consumablesStatus.has_shield || consumablesStatus.has_active_boost) && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/30 rounded-xl">
<p className="text-green-400 text-sm font-medium mb-2">Активные эффекты:</p>
<div className="flex flex-wrap gap-2">
{consumablesStatus.has_shield && (
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg border border-blue-500/30 flex items-center gap-1">
<Shield className="w-3 h-3" /> Shield (следующий drop бесплатный)
</span>
)}
{consumablesStatus.has_active_boost && (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg border border-yellow-500/30 flex items-center gap-1">
<Zap className="w-3 h-3" /> Boost x1.5 (следующий complete)
</span>
)}
</div>
</div>
)}
{/* Consumables grid */}
<div className="grid grid-cols-2 gap-3">
{/* Skip */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<SkipForward className="w-4 h-4 text-orange-400" />
<span className="text-white font-medium">Skip</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.skips_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Пропустить без штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseSkip}
disabled={consumablesStatus.skips_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'skip'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Reroll */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-cyan-400" />
<span className="text-white font-medium">Reroll</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.rerolls_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Переспинить задание</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseReroll}
disabled={consumablesStatus.rerolls_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'reroll'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Shield */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" />
<span className="text-white font-medium">Shield</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_shield ? 'Активен' : `${consumablesStatus.shields_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">Защита от штрафа</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseShield}
disabled={consumablesStatus.has_shield || consumablesStatus.shields_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'shield'}
className="w-full"
>
{consumablesStatus.has_shield ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
{/* Boost */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-400" />
<span className="text-white font-medium">Boost</span>
</div>
<span className="text-gray-400 text-sm">
{consumablesStatus.has_active_boost ? 'Активен' : `${consumablesStatus.boosts_available} шт.`}
</span>
</div>
<p className="text-gray-500 text-xs mb-2">x1.5 очков</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseBoost}
disabled={consumablesStatus.has_active_boost || consumablesStatus.boosts_available === 0 || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'boost'}
className="w-full"
>
{consumablesStatus.has_active_boost ? 'Активен' : 'Активировать'}
</NeonButton>
</div>
</div>
</GlassCard>
)}
{/* Tabs for Common Enemy event */}
{activeEvent?.event?.type === 'common_enemy' && (
<div className="flex gap-2 mb-6">

View File

@@ -4,7 +4,7 @@ import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui'
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react'
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2, BadgeCheck, BadgeX } from 'lucide-react'
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
preparing: {
@@ -108,6 +108,47 @@ export function AdminMarathonsPage() {
}
}
const handleCertify = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Верифицировать марафон',
message: `Верифицировать марафон "${marathon.title}"? Участники смогут зарабатывать монетки.`,
confirmText: 'Верифицировать',
})
if (!confirmed) return
try {
await adminApi.certifyMarathon(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, is_certified: true, certification_status: 'certified' } : m
))
toast.success('Марафон верифицирован')
} catch (err) {
console.error('Failed to certify marathon:', err)
toast.error('Ошибка верификации')
}
}
const handleRevokeCertification = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Отозвать верификацию',
message: `Отозвать верификацию марафона "${marathon.title}"? Участники больше не смогут зарабатывать монетки.`,
confirmText: 'Отозвать',
variant: 'warning',
})
if (!confirmed) return
try {
await adminApi.revokeCertification(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, is_certified: false, certification_status: 'none' } : m
))
toast.success('Верификация отозвана')
} catch (err) {
console.error('Failed to revoke certification:', err)
toast.error('Ошибка отзыва верификации')
}
}
return (
<div className="space-y-6">
{/* Header */}
@@ -145,6 +186,7 @@ export function AdminMarathonsPage() {
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Верификация</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
@@ -154,13 +196,13 @@ export function AdminMarathonsPage() {
<tbody className="divide-y divide-dark-600">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center">
<td colSpan={9} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td>
</tr>
) : marathons.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
<td colSpan={9} className="px-4 py-8 text-center text-gray-400">
Марафоны не найдены
</td>
</tr>
@@ -179,6 +221,19 @@ export function AdminMarathonsPage() {
{statusConfig.label}
</span>
</td>
<td className="px-4 py-3">
{marathon.is_certified ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
<BadgeCheck className="w-3 h-3" />
Верифицирован
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-dark-600/50 text-gray-400 border border-dark-500">
<BadgeX className="w-3 h-3" />
Нет
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">
@@ -188,6 +243,23 @@ export function AdminMarathonsPage() {
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
{marathon.is_certified ? (
<button
onClick={() => handleRevokeCertification(marathon)}
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
title="Отозвать верификацию"
>
<BadgeX className="w-4 h-4" />
</button>
) : (
<button
onClick={() => handleCertify(marathon)}
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
title="Верифицировать"
>
<BadgeCheck className="w-4 h-4" />
</button>
)}
{marathon.status !== 'finished' && (
<button
onClick={() => handleForceFinish(marathon)}

View File

@@ -85,6 +85,7 @@ export interface Marathon {
is_public: boolean
game_proposal_mode: GameProposalMode
auto_events_enabled: boolean
allow_consumables: boolean
cover_url: string | null
start_date: string | null
end_date: string | null
@@ -512,6 +513,8 @@ export interface AdminMarathon {
start_date: string | null
end_date: string | null
created_at: string
certification_status: string
is_certified: boolean
}
export interface PlatformStats {
@@ -802,10 +805,11 @@ export interface ConsumablesStatus {
skips_available: number
skips_used: number
skips_remaining: number | null
shields_available: number
has_shield: boolean
boosts_available: number
has_active_boost: boolean
boost_multiplier: number | null
boost_expires_at: string | null
rerolls_available: number
}