chore: initial import to Nexus_Edu

This commit is contained in:
SpecialX
2025-11-28 19:23:19 +08:00
commit 38244630a7
153 changed files with 22541 additions and 0 deletions

View 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>
);
};