Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -23,11 +23,19 @@ export function AssignmentDetailPage() {
|
||||
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
|
||||
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
|
||||
|
||||
// Bonus proof media
|
||||
const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({})
|
||||
|
||||
// Dispute creation
|
||||
const [showDisputeForm, setShowDisputeForm] = useState(false)
|
||||
const [disputeReason, setDisputeReason] = useState('')
|
||||
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
|
||||
|
||||
// Bonus dispute creation
|
||||
const [activeBonusDisputeId, setActiveBonusDisputeId] = useState<number | null>(null)
|
||||
const [bonusDisputeReason, setBonusDisputeReason] = useState('')
|
||||
const [isCreatingBonusDispute, setIsCreatingBonusDispute] = useState(false)
|
||||
|
||||
// Comment
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [isAddingComment, setIsAddingComment] = useState(false)
|
||||
@@ -38,10 +46,13 @@ export function AssignmentDetailPage() {
|
||||
useEffect(() => {
|
||||
loadAssignment()
|
||||
return () => {
|
||||
// Cleanup blob URL on unmount
|
||||
// Cleanup blob URLs on unmount
|
||||
if (proofMediaBlobUrl) {
|
||||
URL.revokeObjectURL(proofMediaBlobUrl)
|
||||
}
|
||||
Object.values(bonusProofMedia).forEach(media => {
|
||||
URL.revokeObjectURL(media.url)
|
||||
})
|
||||
}
|
||||
}, [id])
|
||||
|
||||
@@ -63,6 +74,22 @@ export function AssignmentDetailPage() {
|
||||
// Ignore error, media just won't show
|
||||
}
|
||||
}
|
||||
|
||||
// Load bonus proof media for playthrough
|
||||
if (data.is_playthrough && data.bonus_challenges) {
|
||||
const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {}
|
||||
for (const bonus of data.bonus_challenges) {
|
||||
if (bonus.proof_image_url) {
|
||||
try {
|
||||
const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id)
|
||||
bonusMedia[bonus.id] = { url, type }
|
||||
} catch {
|
||||
// Ignore error, media just won't show
|
||||
}
|
||||
}
|
||||
}
|
||||
setBonusProofMedia(bonusMedia)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
|
||||
@@ -88,6 +115,37 @@ export function AssignmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateBonusDispute = async (bonusId: number) => {
|
||||
if (!bonusDisputeReason.trim()) return
|
||||
|
||||
setIsCreatingBonusDispute(true)
|
||||
try {
|
||||
await assignmentsApi.createBonusDispute(bonusId, bonusDisputeReason)
|
||||
setBonusDisputeReason('')
|
||||
setActiveBonusDisputeId(null)
|
||||
await loadAssignment()
|
||||
toast.success('Оспаривание бонуса создано')
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
|
||||
} finally {
|
||||
setIsCreatingBonusDispute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBonusVote = async (disputeId: number, vote: boolean) => {
|
||||
setIsVoting(true)
|
||||
try {
|
||||
await assignmentsApi.vote(disputeId, vote)
|
||||
await loadAssignment()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
|
||||
} finally {
|
||||
setIsVoting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = async (vote: boolean) => {
|
||||
if (!assignment?.dispute) return
|
||||
|
||||
@@ -215,39 +273,64 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Challenge info */}
|
||||
{/* Challenge/Playthrough info */}
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
|
||||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||
<div className={`w-14 h-14 rounded-xl border flex items-center justify-center ${
|
||||
assignment.is_playthrough
|
||||
? 'bg-gradient-to-br from-accent-500/20 to-purple-500/20 border-accent-500/20'
|
||||
: 'bg-gradient-to-br from-neon-500/20 to-accent-500/20 border-neon-500/20'
|
||||
}`}>
|
||||
<Gamepad2 className={`w-7 h-7 ${assignment.is_playthrough ? 'text-accent-400' : 'text-neon-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
|
||||
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{assignment.is_playthrough ? assignment.game?.title : assignment.challenge?.game.title}
|
||||
</p>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{assignment.is_playthrough ? 'Прохождение игры' : assignment.challenge?.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.text}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{assignment.is_playthrough && (
|
||||
<span className="px-3 py-1 bg-accent-500/20 text-accent-400 rounded-full text-xs font-medium border border-accent-500/30">
|
||||
Прохождение
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
|
||||
<p className="text-gray-300 mb-4">
|
||||
{assignment.is_playthrough
|
||||
? assignment.playthrough_info?.description
|
||||
: assignment.challenge?.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<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 flex items-center gap-1.5">
|
||||
<Trophy className="w-4 h-4" />
|
||||
+{assignment.challenge.points} очков
|
||||
+{assignment.is_playthrough
|
||||
? assignment.playthrough_info?.points
|
||||
: 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">
|
||||
{assignment.challenge.difficulty}
|
||||
</span>
|
||||
{assignment.challenge.estimated_time && (
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{assignment.challenge.estimated_time} мин
|
||||
</span>
|
||||
{!assignment.is_playthrough && assignment.challenge && (
|
||||
<>
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||
{assignment.challenge.difficulty}
|
||||
</span>
|
||||
{assignment.challenge.estimated_time && (
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{assignment.challenge.estimated_time} мин
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -271,6 +354,185 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Bonus challenges for playthrough */}
|
||||
{assignment.is_playthrough && assignment.bonus_challenges && assignment.bonus_challenges.length > 0 && (
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<Trophy className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Бонусные челленджи</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Выполнено: {assignment.bonus_challenges.filter((b: { status: string }) => b.status === 'completed').length} из {assignment.bonus_challenges.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{assignment.bonus_challenges.map((bonus) => (
|
||||
<div
|
||||
key={bonus.id}
|
||||
className={`p-4 rounded-xl border ${
|
||||
bonus.dispute ? 'bg-yellow-500/10 border-yellow-500/30' :
|
||||
bonus.status === 'completed'
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-dark-700/50 border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{bonus.dispute ? (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
) : bonus.status === 'completed' ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : null}
|
||||
<span className="text-white font-medium">{bonus.challenge.title}</span>
|
||||
{bonus.dispute && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
bonus.dispute.status === 'open' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
bonus.dispute.status === 'valid' ? 'bg-green-500/20 text-green-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{bonus.dispute.status === 'open' ? 'Оспаривается' :
|
||||
bonus.dispute.status === 'valid' ? 'Валидно' : 'Невалидно'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{bonus.challenge.description}</p>
|
||||
{bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment) && (
|
||||
<div className="mt-2 text-xs space-y-2">
|
||||
{bonusProofMedia[bonus.id] && (
|
||||
<div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs">
|
||||
{bonusProofMedia[bonus.id].type === 'video' ? (
|
||||
<video
|
||||
src={bonusProofMedia[bonus.id].url}
|
||||
controls
|
||||
className="w-full max-h-32 bg-dark-900"
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<a href={bonusProofMedia[bonus.id].url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={bonusProofMedia[bonus.id].url}
|
||||
alt="Proof"
|
||||
className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{bonus.proof_url && (
|
||||
<a
|
||||
href={bonus.proof_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-neon-400 hover:underline flex items-center gap-1 break-all"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||
{bonus.proof_url}
|
||||
</a>
|
||||
)}
|
||||
{bonus.proof_comment && (
|
||||
<p className="text-gray-400">"{bonus.proof_comment}"</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bonus dispute form */}
|
||||
{activeBonusDisputeId === bonus.id && (
|
||||
<div className="mt-3 p-3 bg-red-500/10 rounded-lg border border-red-500/30">
|
||||
<textarea
|
||||
className="input w-full min-h-[80px] resize-none mb-2 text-sm"
|
||||
placeholder="Причина оспаривания (минимум 10 символов)..."
|
||||
value={bonusDisputeReason}
|
||||
onChange={(e) => setBonusDisputeReason(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 disabled:opacity-50"
|
||||
onClick={() => handleCreateBonusDispute(bonus.id)}
|
||||
disabled={bonusDisputeReason.trim().length < 10 || isCreatingBonusDispute}
|
||||
>
|
||||
{isCreatingBonusDispute ? 'Создание...' : 'Оспорить'}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-dark-600 text-gray-300 rounded-lg hover:bg-dark-500"
|
||||
onClick={() => {
|
||||
setActiveBonusDisputeId(null)
|
||||
setBonusDisputeReason('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bonus dispute info */}
|
||||
{bonus.dispute && (
|
||||
<div className="mt-3 p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||
<p className="text-xs text-gray-400 mb-1">
|
||||
Оспорил: <span className="text-white">{bonus.dispute.raised_by.nickname}</span>
|
||||
</p>
|
||||
<p className="text-sm text-white mb-2">{bonus.dispute.reason}</p>
|
||||
|
||||
{bonus.dispute.status === 'open' && (
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThumbsUp className="w-3 h-3 text-green-400" />
|
||||
<span className="text-green-400 text-sm font-medium">{bonus.dispute.votes_valid}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ThumbsDown className="w-3 h-3 text-red-400" />
|
||||
<span className="text-red-400 text-sm font-medium">{bonus.dispute.votes_invalid}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<button
|
||||
className={`p-1.5 rounded ${bonus.dispute.my_vote === true ? 'bg-green-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
|
||||
onClick={() => handleBonusVote(bonus.dispute!.id, true)}
|
||||
disabled={isVoting}
|
||||
>
|
||||
<ThumbsUp className="w-3 h-3 text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded ${bonus.dispute.my_vote === false ? 'bg-red-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
|
||||
onClick={() => handleBonusVote(bonus.dispute!.id, false)}
|
||||
disabled={isVoting}
|
||||
>
|
||||
<ThumbsDown className="w-3 h-3 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3 flex flex-col items-end gap-2">
|
||||
{bonus.status === 'completed' ? (
|
||||
<span className="text-green-400 font-semibold">+{bonus.points_earned}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">+{bonus.challenge.points}</span>
|
||||
)}
|
||||
{/* Dispute button for bonus */}
|
||||
{bonus.can_dispute && !bonus.dispute && activeBonusDisputeId !== bonus.id && (
|
||||
<button
|
||||
className="text-xs px-2 py-1 text-red-400 hover:bg-red-500/10 rounded flex items-center gap-1"
|
||||
onClick={() => setActiveBonusDisputeId(bonus.id)}
|
||||
>
|
||||
<Flag className="w-3 h-3" />
|
||||
Оспорить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Proof section */}
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
Reference in New Issue
Block a user