Улучшение системы оспариваний и исправления

- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
2025-12-29 22:23:34 +03:00
parent 1cedfeb3ee
commit 89dbe2c018
42 changed files with 5426 additions and 313 deletions

View File

@@ -55,6 +55,15 @@ export function PlayPage() {
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
// Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
const [bonusProofFile, setBonusProofFile] = useState<File | null>(null)
const [bonusProofUrl, setBonusProofUrl] = useState('')
const [bonusComment, setBonusComment] = useState('')
const [isCompletingBonus, setIsCompletingBonus] = useState(false)
const bonusFileInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const eventFileInputRef = useRef<HTMLInputElement>(null)
@@ -168,17 +177,17 @@ export function PlayPage() {
const loadData = async () => {
if (!id) return
try {
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'),
gamesApi.getAvailableGames(parseInt(id)),
eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
setGames(gamesData)
setGames(availableGamesData)
setActiveEvent(eventData)
setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData)
@@ -219,9 +228,19 @@ export function PlayPage() {
const handleComplete = async () => {
if (!currentAssignment) return
if (!proofFile && !proofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
// For playthrough: allow file, URL, or comment
// For challenges: require file or URL
if (currentAssignment.is_playthrough) {
if (!proofFile && !proofUrl && !comment) {
toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)')
return
}
} else {
if (!proofFile && !proofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
}
setIsCompleting(true)
@@ -270,6 +289,39 @@ export function PlayPage() {
}
}
const handleBonusComplete = async (bonusId: number) => {
if (!currentAssignment) return
if (!bonusProofFile && !bonusProofUrl && !bonusComment) {
toast.warning('Прикрепите файл, ссылку или комментарий')
return
}
setIsCompletingBonus(true)
try {
const result = await assignmentsApi.completeBonusAssignment(
currentAssignment.id,
bonusId,
{
proof_file: bonusProofFile || undefined,
proof_url: bonusProofUrl || undefined,
comment: bonusComment || undefined,
}
)
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
setBonusProofFile(null)
setBonusProofUrl('')
setBonusComment('')
setExpandedBonusId(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить бонус')
} finally {
setIsCompletingBonus(false)
}
}
const handleEventComplete = async () => {
if (!eventAssignment?.assignment) return
if (!eventProofFile && !eventProofUrl) {
@@ -529,12 +581,23 @@ export function PlayPage() {
>
<div className="flex items-start justify-between">
<div>
<p className="text-white font-medium">{ra.challenge.title}</p>
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
{ra.is_playthrough ? (
<>
<p className="text-white font-medium">Прохождение: {ra.game_title}</p>
<p className="text-gray-400 text-sm">Прохождение игры</p>
</>
) : ra.challenge ? (
<>
<p className="text-white font-medium">{ra.challenge.title}</p>
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
</>
) : null}
</div>
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30">
+{ra.challenge.points}
</span>
{!ra.is_playthrough && ra.challenge && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30">
+{ra.challenge.points}
</span>
)}
</div>
<p className="text-orange-300 text-xs mt-2">
Причина: {ra.dispute_reason}
@@ -640,28 +703,28 @@ export function PlayPage() {
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p>
<p className="text-xl font-bold text-white">
{eventAssignment.assignment.challenge.game.title}
{eventAssignment.assignment.challenge?.game.title}
</p>
</div>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2">
{eventAssignment.assignment.challenge.title}
{eventAssignment.assignment.challenge?.title}
</p>
<p className="text-gray-300">
{eventAssignment.assignment.challenge.description}
{eventAssignment.assignment.challenge?.description}
</p>
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{eventAssignment.assignment.challenge.points} очков
+{eventAssignment.assignment.challenge?.points} очков
</span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{eventAssignment.assignment.challenge.difficulty}
{eventAssignment.assignment.challenge?.difficulty}
</span>
{eventAssignment.assignment.challenge.estimated_time && (
{eventAssignment.assignment.challenge?.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{eventAssignment.assignment.challenge.estimated_time} мин
@@ -669,7 +732,7 @@ export function PlayPage() {
)}
</div>
{eventAssignment.assignment.challenge.proof_hint && (
{eventAssignment.assignment.challenge?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
@@ -680,7 +743,7 @@ export function PlayPage() {
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
Загрузить доказательство ({eventAssignment.assignment.challenge?.proof_type})
</label>
<input
@@ -891,55 +954,248 @@ export function PlayPage() {
<>
<GlassCard variant="neon">
<div className="text-center mb-6">
<span className="px-4 py-1.5 bg-neon-500/20 text-neon-400 rounded-full text-sm font-medium border border-neon-500/30">
Активное задание
<span className={`px-4 py-1.5 rounded-full text-sm font-medium border ${
currentAssignment.is_playthrough
? 'bg-accent-500/20 text-accent-400 border-accent-500/30'
: 'bg-neon-500/20 text-neon-400 border-neon-500/30'
}`}>
{currentAssignment.is_playthrough ? 'Прохождение игры' : 'Активное задание'}
</span>
</div>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p>
<p className="text-xl font-bold text-white">
{currentAssignment.challenge.game.title}
{currentAssignment.is_playthrough
? currentAssignment.game?.title
: currentAssignment.challenge?.game.title}
</p>
</div>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2">
{currentAssignment.challenge.title}
</p>
<p className="text-gray-300">
{currentAssignment.challenge.description}
</p>
</div>
{currentAssignment.is_playthrough ? (
// Playthrough task
<>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задача</p>
<p className="text-xl font-bold text-accent-400 mb-2">
Пройти игру
</p>
<p className="text-gray-300">
{currentAssignment.playthrough_info?.description}
</p>
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{currentAssignment.challenge.points} очков
</span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{currentAssignment.challenge.difficulty}
</span>
{currentAssignment.challenge.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{currentAssignment.challenge.estimated_time} мин
</span>
)}
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{currentAssignment.playthrough_info?.points} очков
</span>
</div>
{currentAssignment.challenge.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
</p>
</div>
{currentAssignment.playthrough_info?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.playthrough_info.proof_hint}
</p>
</div>
)}
{/* Bonus challenges */}
{currentAssignment.bonus_challenges && currentAssignment.bonus_challenges.length > 0 && (
<div className="mb-6 p-4 bg-accent-500/10 rounded-xl border border-accent-500/20">
<p className="text-accent-400 font-medium mb-3">
Бонусные челленджи (опционально) {currentAssignment.bonus_challenges.filter(b => b.status === 'completed').length}/{currentAssignment.bonus_challenges.length}
</p>
<div className="space-y-2">
{currentAssignment.bonus_challenges.map((bonus) => (
<div
key={bonus.id}
className={`rounded-lg border overflow-hidden ${
bonus.status === 'completed'
? 'bg-green-500/10 border-green-500/30'
: 'bg-dark-700/50 border-dark-600'
}`}
>
{/* Bonus header */}
<div
className={`p-3 flex items-center justify-between ${
bonus.status === 'pending' ? 'cursor-pointer hover:bg-dark-600/50' : ''
}`}
onClick={() => {
if (bonus.status === 'pending') {
setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id)
setBonusProofFile(null)
setBonusProofUrl('')
setBonusComment('')
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}
}}
>
<div className="flex-1">
<div className="flex items-center gap-2">
{bonus.status === 'completed' && (
<Check className="w-4 h-4 text-green-400" />
)}
<p className="text-white font-medium text-sm">{bonus.challenge.title}</p>
</div>
<p className="text-gray-400 text-xs mt-0.5">{bonus.challenge.description}</p>
</div>
<div className="text-right shrink-0 ml-2">
{bonus.status === 'completed' ? (
<span className="text-green-400 text-sm font-medium">+{bonus.points_earned}</span>
) : (
<span className="text-accent-400 text-sm">+{bonus.challenge.points}</span>
)}
</div>
</div>
{/* Expanded form for completing */}
{expandedBonusId === bonus.id && bonus.status === 'pending' && (
<div className="p-3 border-t border-dark-600 bg-dark-800/50 space-y-3">
{bonus.challenge.proof_hint && (
<p className="text-xs text-gray-400">
<strong className="text-white">Пруф:</strong> {bonus.challenge.proof_hint}
</p>
)}
{/* File upload */}
<input
ref={bonusFileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => {
e.stopPropagation()
validateAndSetFile(e.target.files?.[0] || null, setBonusProofFile, bonusFileInputRef)
}}
/>
{bonusProofFile ? (
<div className="flex items-center gap-2 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
<span className="text-white text-sm flex-1 truncate">{bonusProofFile.name}</span>
<button
onClick={(e) => {
e.stopPropagation()
setBonusProofFile(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}}
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
bonusFileInputRef.current?.click()
}}
className="w-full p-2 border border-dashed border-dark-500 rounded-lg text-gray-400 text-sm hover:border-accent-400 hover:text-accent-400 transition-colors flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" />
Загрузить файл
</button>
)}
<div className="text-center text-gray-500 text-xs">или</div>
<input
type="text"
className="input text-sm"
placeholder="Ссылка на пруф (YouTube, Steam и т.д.)"
value={bonusProofUrl}
onChange={(e) => setBonusProofUrl(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
<textarea
className="input text-sm resize-none"
placeholder="Комментарий (необязательно)"
rows={2}
value={bonusComment}
onChange={(e) => setBonusComment(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<NeonButton
size="sm"
onClick={(e) => {
e.stopPropagation()
handleBonusComplete(bonus.id)
}}
isLoading={isCompletingBonus}
disabled={!bonusProofFile && !bonusProofUrl && !bonusComment}
icon={<Check className="w-3 h-3" />}
>
Выполнено
</NeonButton>
<NeonButton
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setBonusProofFile(null)
setBonusProofUrl('')
setBonusComment('')
setExpandedBonusId(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}}
>
Отмена
</NeonButton>
</div>
</div>
)}
</div>
))}
</div>
<p className="text-xs text-gray-500 mt-2">
Нажмите на бонус, чтобы отметить. Очки начислятся при завершении игры.
</p>
</div>
)}
</>
) : (
// Regular challenge
<>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2">
{currentAssignment.challenge?.title}
</p>
<p className="text-gray-300">
{currentAssignment.challenge?.description}
</p>
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
+{currentAssignment.challenge?.points} очков
</span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{currentAssignment.challenge?.difficulty}
</span>
{currentAssignment.challenge?.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{currentAssignment.challenge.estimated_time} мин
</span>
)}
</div>
{currentAssignment.challenge?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
</p>
</div>
)}
</>
)}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({currentAssignment.challenge.proof_type})
Загрузить доказательство ({currentAssignment.is_playthrough
? currentAssignment.playthrough_info?.proof_type
: currentAssignment.challenge?.proof_type})
</label>
<input
@@ -1000,7 +1256,10 @@ export function PlayPage() {
className="flex-1"
onClick={handleComplete}
isLoading={isCompleting}
disabled={!proofFile && !proofUrl}
disabled={currentAssignment.is_playthrough
? (!proofFile && !proofUrl && !comment)
: (!proofFile && !proofUrl)
}
icon={<Check className="w-4 h-4" />}
>
Выполнено