Files
Nexus_Edu/src/features/assignment/components/CreateAssignmentModal.tsx
2025-11-28 19:23:19 +08:00

269 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, CheckCircle2, Search, FileText, Users, Calendar, Clock, ArrowRight, Loader2 } from 'lucide-react';
import { ExamDto, ClassDto } from '../../../../UI_DTO';
import { examService, orgService, assignmentService } from '@/services/api';
import { useToast } from '@/components/ui/Toast';
interface CreateAssignmentModalProps {
onClose: () => void;
onSuccess: () => void;
}
export const CreateAssignmentModal: React.FC<CreateAssignmentModalProps> = ({ onClose, onSuccess }) => {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const { showToast } = useToast();
const [exams, setExams] = useState<ExamDto[]>([]);
const [classes, setClasses] = useState<ClassDto[]>([]);
const [selectedExam, setSelectedExam] = useState<ExamDto | null>(null);
const [selectedClassIds, setSelectedClassIds] = useState<string[]>([]);
const [config, setConfig] = useState({
title: '',
startDate: new Date().toISOString().split('T')[0],
dueDate: new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]
});
useEffect(() => {
examService.getMyExams().then(res => setExams(res.items));
orgService.getClasses().then(setClasses);
}, []);
useEffect(() => {
if (selectedExam && !config.title) {
setConfig(prev => ({ ...prev, title: selectedExam.title + ' - 作业' }));
}
}, [selectedExam]);
const handlePublish = async () => {
setLoading(true);
try {
await assignmentService.publishAssignment({
examId: selectedExam?.id,
classIds: selectedClassIds,
...config
});
showToast('作业发布成功!', 'success');
onSuccess();
} catch (e) {
showToast('发布失败,请重试', 'error');
} finally {
setLoading(false);
}
};
const steps = [
{ num: 1, label: '选择试卷' },
{ num: 2, label: '选择班级' },
{ num: 3, label: '发布设置' }
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={onClose} />
{/* Fix: cast props to any to avoid framer-motion type errors */}
<motion.div
{...({
initial: { opacity: 0, scale: 0.95, y: 20 },
animate: { opacity: 1, scale: 1, y: 0 }
} as any)}
className="bg-white w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden relative z-10 flex flex-col max-h-[90vh]"
>
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50 backdrop-blur">
<h3 className="font-bold text-lg text-gray-900"></h3>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full text-gray-500"><X size={20}/></button>
</div>
<div className="px-8 py-6">
<div className="flex items-center justify-between relative">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-gray-100 -z-10" />
{steps.map((s) => (
<div key={s.num} className="flex flex-col items-center gap-2 bg-white px-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all border-2 ${step >= s.num ? 'bg-blue-600 border-blue-600 text-white' : 'bg-white border-gray-200 text-gray-400'}`}>
{step > s.num ? <CheckCircle2 size={18} /> : s.num}
</div>
<span className={`text-xs font-bold ${step >= s.num ? 'text-blue-600' : 'text-gray-400'}`}>{s.label}</span>
</div>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 min-h-[300px]">
<AnimatePresence mode='wait'>
{step === 1 && (
// Fix: cast props to any to avoid framer-motion type errors
<motion.div
key="step1"
{...({
initial: { x: 20, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: -20, opacity: 0 }
} as any)}
className="space-y-4"
>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input placeholder="搜索试卷..." className="w-full pl-10 pr-4 py-3 bg-gray-50 rounded-xl border-none outline-none focus:ring-2 focus:ring-blue-500/20" />
</div>
<div className="space-y-2 max-h-[400px] overflow-y-auto custom-scrollbar">
{exams.map(exam => (
<div
key={exam.id}
onClick={() => setSelectedExam(exam)}
className={`p-4 rounded-xl border cursor-pointer transition-all flex items-center gap-4
${selectedExam?.id === exam.id ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500' : 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'}
`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${selectedExam?.id === exam.id ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-500'}`}>
<FileText size={20} />
</div>
<div className="flex-1">
<h4 className={`font-bold ${selectedExam?.id === exam.id ? 'text-blue-800' : 'text-gray-900'}`}>{exam.title}</h4>
<div className="flex gap-3 mt-1 text-xs text-gray-500">
<span>{exam.questionCount} </span>
<span>{exam.duration} </span>
<span> {exam.totalScore}</span>
</div>
</div>
{selectedExam?.id === exam.id && <CheckCircle2 className="text-blue-600" />}
</div>
))}
</div>
</motion.div>
)}
{step === 2 && (
// Fix: cast props to any to avoid framer-motion type errors
<motion.div
key="step2"
{...({
initial: { x: 20, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: -20, opacity: 0 }
} as any)}
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
{classes.map(cls => {
const isSelected = selectedClassIds.includes(cls.id);
return (
<div
key={cls.id}
onClick={() => {
setSelectedClassIds(prev =>
isSelected ? prev.filter(id => id !== cls.id) : [...prev, cls.id]
);
}}
className={`p-4 rounded-xl border cursor-pointer transition-all flex items-start gap-4
${isSelected ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500' : 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'}
`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isSelected ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-500'}`}>
<Users size={20} />
</div>
<div className="flex-1">
<h4 className={`font-bold ${isSelected ? 'text-blue-800' : 'text-gray-900'}`}>{cls.name}</h4>
<p className="text-xs text-gray-500 mt-1">{cls.studentCount} </p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${isSelected ? 'border-blue-500 bg-blue-500 text-white' : 'border-gray-300'}`}>
{isSelected && <CheckCircle2 size={12} />}
</div>
</div>
)
})}
</motion.div>
)}
{step === 3 && (
// Fix: cast props to any to avoid framer-motion type errors
<motion.div
key="step3"
{...({
initial: { x: 20, opacity: 0 },
animate: { x: 0, opacity: 1 },
exit: { x: -20, opacity: 0 }
} as any)}
className="space-y-6"
>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700"></label>
<input
value={config.title}
onChange={e => setConfig({...config, title: e.target.value})}
className="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700"></label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18}/>
<input
type="date"
value={config.startDate}
onChange={e => setConfig({...config, startDate: e.target.value})}
className="w-full pl-10 pr-3 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 transition-colors"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-gray-700"></label>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18}/>
<input
type="date"
value={config.dueDate}
onChange={e => setConfig({...config, dueDate: e.target.value})}
className="w-full pl-10 pr-3 py-3 bg-gray-50 border border-gray-200 rounded-xl outline-none focus:border-blue-500 transition-colors"
/>
</div>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100">
<h4 className="font-bold text-blue-800 mb-2"></h4>
<ul className="space-y-2 text-sm text-blue-600/80">
<li> {selectedExam?.title}</li>
<li> {selectedClassIds.length} </li>
<li> {selectedExam?.duration} </li>
</ul>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="p-6 border-t border-gray-100 bg-gray-50/50 backdrop-blur flex justify-between items-center">
{step > 1 ? (
<button onClick={() => setStep(step - 1)} className="text-gray-500 font-bold hover:text-gray-900 px-4 py-2"></button>
) : (
<div />
)}
{step < 3 ? (
<button
onClick={() => setStep(step + 1)}
disabled={step === 1 && !selectedExam || step === 2 && selectedClassIds.length === 0}
className="bg-gray-900 text-white px-6 py-3 rounded-xl font-bold shadow-lg hover:bg-black disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-all"
>
<ArrowRight size={18} />
</button>
) : (
<button
onClick={handlePublish}
disabled={loading}
className="bg-blue-600 text-white px-8 py-3 rounded-xl font-bold shadow-lg shadow-blue-500/30 hover:bg-blue-700 disabled:opacity-70 flex items-center gap-2 transition-all"
>
{loading ? <Loader2 className="animate-spin" /> : <CheckCircle2 size={18} />}
</button>
)}
</div>
</motion.div>
</div>
);
};