chore: initial import to Nexus_Edu
This commit is contained in:
269
src/features/assignment/components/CreateAssignmentModal.tsx
Normal file
269
src/features/assignment/components/CreateAssignmentModal.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user