stud-manager / pages /StudentList.tsx
dvc890's picture
Upload 51 files
07d35d2 verified
import React, { useState, useEffect } from 'react';
import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User, FileSpreadsheet, ArrowRightLeft } from 'lucide-react';
import { api } from '../services/api';
import { Student, ClassInfo } from '../types';
const localSortGrades = (a: string, b: string) => {
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
return (order[a] || 99) - (order[b] || 99);
};
export const StudentList: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [students, setStudents] = useState<Student[]>([]);
const [classList, setClassList] = useState<ClassInfo[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isImportOpen, setIsImportOpen] = useState(false);
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [editStudentId, setEditStudentId] = useState<string | null>(null);
const [transferTargetClass, setTransferTargetClass] = useState('');
const [selectedGrade, setSelectedGrade] = useState('All');
const [selectedClass, setSelectedClass] = useState('All');
const [importFile, setImportFile] = useState<File | null>(null);
const [importTargetClass, setImportTargetClass] = useState('');
const initialForm = {
name: '',
studentNo: '', // System ID
seatNo: '', // Seat No
gender: 'Male',
className: '',
phone: '',
idCard: '',
parentName: '',
parentPhone: '',
address: ''
};
const [formData, setFormData] = useState(initialForm);
const currentUser = api.auth.getCurrentUser();
const loadData = async () => {
setLoading(true);
try {
const [studentData, classData] = await Promise.all([
api.students.getAll(),
api.classes.getAll()
]);
// Sort students: Primary by SeatNo (if numeric), Secondary by System ID
const sorted = studentData.sort((a: Student, b: Student) => {
const seatA = parseInt(a.seatNo || '9999');
const seatB = parseInt(b.seatNo || '9999');
if (seatA !== seatB) return seatA - seatB;
return a.studentNo.localeCompare(b.studentNo, undefined, {numeric: true});
});
setStudents(sorted);
setClassList(classData);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
useEffect(() => { loadData(); }, []);
const hasPermission = (className: string) => {
if (!currentUser) return false;
if (currentUser.role === 'ADMIN') return true;
if (currentUser.role === 'TEACHER') {
return currentUser.homeroomClass === className;
}
return false;
};
const handleDelete = async (id: string, className: string) => {
if (!hasPermission(className)) return alert('您没有权限操作此班级学生');
if (confirm('确定删除?这将同时删除该学生的账号和所有数据。')) {
await api.students.delete(id);
loadData();
}
};
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
if (confirm(`确定要删除选中的 ${selectedIds.size} 名学生吗?`)) {
await api.batchDelete('student', Array.from(selectedIds));
setSelectedIds(new Set());
loadData();
}
};
const toggleSelect = (id: string) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
setSelectedIds(newSet);
};
const toggleSelectAll = (filtered: Student[]) => {
if (selectedIds.size === filtered.length && filtered.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filtered.map(s => s._id || String(s.id))));
}
};
const handleOpenAdd = () => {
setEditStudentId(null);
setFormData(initialForm);
setIsModalOpen(true);
};
const handleOpenEdit = (student: Student) => {
if (!hasPermission(student.className)) return alert('无权编辑此学生');
setEditStudentId(student._id || String(student.id));
setFormData({
name: student.name,
studentNo: student.studentNo,
seatNo: student.seatNo || '',
gender: (student.gender === 'Female' ? 'Female' : 'Male'),
className: student.className,
phone: student.phone || '',
idCard: student.idCard || '',
parentName: student.parentName || '',
parentPhone: student.parentPhone || '',
address: student.address || ''
});
setIsModalOpen(true);
};
const handleOpenTransfer = (student: Student) => {
if (!hasPermission(student.className)) return alert('无权操作调班');
setEditStudentId(student._id || String(student.id));
setTransferTargetClass(student.className);
setIsTransferOpen(true);
};
const submitTransfer = async () => {
if (!editStudentId || !transferTargetClass) return;
if (confirm(`确定将学生调转至 ${transferTargetClass} 吗?`)) {
setSubmitting(true);
try {
await api.students.transfer({ studentId: editStudentId, targetClass: transferTargetClass });
setIsTransferOpen(false);
loadData();
} catch(e) { alert('调班失败'); }
finally { setSubmitting(false); }
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
const payload = {
...formData,
birthday: '2015-01-01',
status: 'Enrolled',
gender: formData.gender as any
};
if (editStudentId) {
await api.students.update(editStudentId, payload);
} else {
await api.students.add(payload);
}
setIsModalOpen(false);
loadData();
} catch (error) { alert(editStudentId ? '更新失败' : '添加失败'); }
finally { setSubmitting(false); }
};
const handleExcelImport = async () => {
if (!importFile) return alert('请选择文件');
setSubmitting(true);
// Dynamic import to avoid loading XLSX on initial page load
let XLSX: any;
try {
XLSX = await import('xlsx');
} catch (e) {
setSubmitting(false);
return alert('加载 Excel 解析组件失败,请检查网络');
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = e.target?.result;
const workbook = XLSX.read(data, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const rawRows: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
if (rawRows.length < 1) return alert('文件为空');
let headerRowIndex = -1;
const headerKeywords = ['姓名', 'name', '学号', 'no', 'id'];
for (let i = 0; i < Math.min(10, rawRows.length); i++) {
const rowStr = rawRows[i].join(' ').toLowerCase();
if (headerKeywords.some(k => rowStr.includes(k))) {
headerRowIndex = i;
break;
}
}
let successCount = 0;
const promises: Promise<any>[] = [];
let colMap: Record<string, number> = {};
if (headerRowIndex !== -1) {
rawRows[headerRowIndex].forEach((cell: any, idx: number) => {
if (typeof cell === 'string') colMap[cell.trim()] = idx;
});
} else {
colMap = { '学号': 0, '姓名': 1, '性别': 2, '电话': 3 };
}
const startRow = headerRowIndex === -1 ? 0 : headerRowIndex + 1;
for (let i = startRow; i < rawRows.length; i++) {
const row = rawRows[i];
if (!row || row.length === 0) continue;
const getVal = (keys: string[], defaultIdx: number) => {
for (const k of keys) {
if (colMap[k] !== undefined && row[colMap[k]]) return row[colMap[k]];
}
if (headerRowIndex === -1 && row[defaultIdx]) return row[defaultIdx];
return '';
};
const name = getVal(['姓名', 'Name'], 1);
const seatNo = getVal(['学号', '座号', 'No', 'ID'], 0); // Re-mapped: treat import "No" as Seat No
if (!name) continue;
const genderVal = getVal(['性别', 'Gender'], 2);
const gender = (genderVal === '女' || genderVal === 'Female') ? 'Female' : 'Male';
const targetClassName = importTargetClass || getVal(['班级', 'Class'], 99) || '未分配';
if (!hasPermission(targetClassName)) {
console.warn('Skipping import for class due to permission:', targetClassName);
continue;
}
promises.push(
api.students.add({
name: String(name).trim(),
seatNo: seatNo ? String(seatNo).trim() : '',
studentNo: '', // Auto Generate
gender,
className: targetClassName,
phone: String(getVal(['电话', '联系方式', 'Mobile'], 3)).trim(),
parentName: String(getVal(['家长', '家长姓名', 'Parent'], 4)).trim(),
parentPhone: String(getVal(['家长电话', '联系电话'], 5)).trim(),
address: String(getVal(['地址', '住址', 'Address'], 6)).trim(),
birthday: '2015-01-01',
status: 'Enrolled'
}).then(() => successCount++).catch(err => console.error(err))
);
}
await Promise.all(promises);
alert(`成功导入/更新 ${successCount} 名学生信息`);
setIsImportOpen(false);
setImportFile(null);
loadData();
} catch (err) { console.error(err); alert('解析失败'); }
finally { setSubmitting(false); }
};
reader.readAsArrayBuffer(importFile);
};
const filteredStudents = students.filter((s) => {
const matchesSearch = s.name.toLowerCase().includes(searchTerm.toLowerCase()) || s.studentNo.includes(searchTerm);
const matchesGrade = selectedGrade === 'All' || s.className.includes(selectedGrade);
const matchesClass = selectedClass === 'All' || s.className.includes(selectedClass);
return matchesSearch && matchesGrade && matchesClass;
});
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(localSortGrades);
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
const canGlobalAdd = currentUser?.role === 'ADMIN' || (currentUser?.role === 'TEACHER' && currentUser.homeroomClass);
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden relative min-h-[600px] flex flex-col">
{loading && <div className="absolute inset-0 bg-white/50 z-10 flex items-center justify-center"><Loader2 className="animate-spin text-blue-600" /></div>}
<div className="p-4 md:p-6 border-b border-gray-100 space-y-4">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-lg font-bold text-gray-800">学生档案</h2>
<p className="text-sm text-gray-500">选中 {selectedIds.size} / {filteredStudents.length} 人</p>
</div>
{canGlobalAdd && (
<div className="flex gap-2 w-full md:w-auto">
<button onClick={() => { setIsImportOpen(true); setImportFile(null); }} className="flex-1 md:flex-none btn-secondary flex items-center justify-center space-x-2 px-3 py-2 border rounded-lg text-sm bg-emerald-50 text-emerald-600 hover:bg-emerald-100 border-emerald-200">
<FileSpreadsheet size={16}/><span>Excel导入</span>
</button>
<button onClick={handleOpenAdd} className="flex-1 md:flex-none btn-primary flex items-center justify-center space-x-2 px-3 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
<Plus size={16}/><span>新增</span>
</button>
</div>
)}
</div>
<div className="flex flex-col md:flex-row gap-2 md:gap-4 bg-gray-50 p-3 rounded-lg">
<div className="relative flex-1">
<input type="text" placeholder="搜索姓名或系统ID..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-full pl-9 pr-3 py-2 border rounded-md text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500" />
<Search className="absolute left-3 top-2.5 text-gray-500" size={16} />
</div>
<div className="flex gap-2">
<select className="flex-1 md:w-32 border rounded-md text-sm text-gray-900 p-2" value={selectedGrade} onChange={e => { setSelectedGrade(e.target.value); setSelectedClass('All'); }}><option value="All">所有年级</option>{uniqueGrades.map(g=><option key={g} value={g}>{g}</option>)}</select>
<select className="flex-1 md:w-32 border rounded-md text-sm text-gray-900 p-2" value={selectedClass} onChange={e => setSelectedClass(e.target.value)}><option value="All">所有班级</option>{uniqueClasses.map(c=><option key={c} value={c}>{c}</option>)}</select>
</div>
</div>
</div>
<div className="flex-1 overflow-x-auto">
<table className="w-full text-left min-w-[700px]">
<thead className="bg-gray-50 text-xs text-gray-500 uppercase sticky top-0">
<tr>
<th className="px-6 py-3 w-10">
{canGlobalAdd && (
<input type="checkbox"
checked={filteredStudents.length > 0 && selectedIds.size === filteredStudents.length}
onChange={() => toggleSelectAll(filteredStudents)}
/>
)}
</th>
<th className="px-6 py-3">座号 / 系统ID</th>
<th className="px-6 py-3">姓名/性别</th>
<th className="px-6 py-3">班级</th>
<th className="px-6 py-3">家长/住址</th>
<th className="px-6 py-3 text-right">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredStudents.length > 0 ? filteredStudents.map(s => {
const canEdit = hasPermission(s.className);
return (
<tr key={s._id || s.id} className="hover:bg-blue-50/30 transition-colors">
<td className="px-6 py-4">
{canEdit && <input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />}
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="font-bold text-gray-800 text-lg">{s.seatNo || '-'}</span>
<span className="text-xs text-gray-400 font-mono" title="系统登录账号">{s.studentNo}</span>
</div>
</td>
<td className="px-6 py-4 flex items-center space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs text-white ${s.gender==='Male'?'bg-blue-400':'bg-pink-400'}`}>{s.name[0]}</div>
<div>
<span className="font-bold text-gray-800">{s.name}</span>
<div className="text-xs text-gray-400">{s.idCard || '身份证未录入'}</div>
</div>
</td>
<td className="px-6 py-4 text-sm">
<span className="bg-gray-100 px-2 py-1 rounded text-gray-600 text-xs">{s.className}</span>
</td>
<td className="px-6 py-4 text-xs text-gray-500 max-w-xs truncate">
{s.parentName ? (
<div className="flex flex-col">
<span>{s.parentName} {s.parentPhone}</span>
<span className="text-gray-400 truncate" title={s.address}>{s.address}</span>
</div>
) : '-'}
</td>
<td className="px-6 py-4 text-right flex justify-end space-x-2">
{canEdit ? (
<>
<button onClick={() => handleOpenTransfer(s)} className="text-amber-500 hover:text-amber-700" title="调班"><ArrowRightLeft size={16}/></button>
<button onClick={() => handleOpenEdit(s)} className="text-blue-400 hover:text-blue-600" title="编辑"><Edit size={16}/></button>
<button onClick={() => handleDelete(s._id || String(s.id), s.className)} className="text-red-400 hover:text-red-600" title="删除"><Trash2 size={16}/></button>
</>
) : <span className="text-gray-300 text-xs">只读</span>}
</td>
</tr>
)}) : (
<tr>
<td colSpan={6} className="text-center py-10 text-gray-400">没有找到匹配的学生</td>
</tr>
)}
</tbody>
</table>
</div>
{selectedIds.size > 0 && canGlobalAdd && (
<div className="p-4 border-t border-gray-200 bg-white flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] z-20">
<span className="text-sm font-medium text-gray-700">已选择 {selectedIds.size} 项</span>
<button onClick={handleBatchDelete} className="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700">批量删除</button>
</div>
)}
{isModalOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h3 className="font-bold text-lg mb-4">{editStudentId ? '编辑学生档案' : '新增学生档案'}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-bold text-gray-500 block mb-1">姓名 *</label>
<input className="w-full border p-2 rounded" value={formData.name} onChange={e=>setFormData({...formData, name:e.target.value})} required/>
</div>
<div>
<label className="text-xs font-bold text-gray-500 block mb-1">班级座号 (选填)</label>
<input className="w-full border p-2 rounded" value={formData.seatNo} onChange={e=>setFormData({...formData, seatNo:e.target.value})} placeholder="如: 05"/>
</div>
</div>
{editStudentId && (
<div className="bg-gray-50 p-2 rounded text-xs text-gray-600 mb-2">
<span className="font-bold">系统ID (登录账号): </span> {formData.studentNo}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<select className="w-full border p-2 rounded" value={formData.className} onChange={e=>setFormData({...formData, className:e.target.value})} required>
<option value="">选择班级 *</option>
{classList.map(c => {
if(currentUser?.role === 'TEACHER' && currentUser.homeroomClass && c.grade+c.className !== currentUser.homeroomClass) return null;
return <option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>
})}
</select>
<select className="w-full border p-2 rounded" value={formData.gender} onChange={e=>setFormData({...formData, gender:e.target.value})} required>
<option value="Male"></option>
<option value="Female"></option>
</select>
</div>
<input className="w-full border p-2 rounded" placeholder="身份证号 (选填)" value={formData.idCard} onChange={e=>setFormData({...formData, idCard:e.target.value})}/>
<div className="border-t border-gray-100 pt-4 mt-2">
<p className="text-xs font-bold text-gray-500 uppercase mb-2">家庭信息 (选填)</p>
<div className="grid grid-cols-2 gap-4 mb-2">
<input className="w-full border p-2 rounded" placeholder="家长姓名" value={formData.parentName} onChange={e=>setFormData({...formData, parentName:e.target.value})}/>
<input className="w-full border p-2 rounded" placeholder="家长电话" value={formData.parentPhone} onChange={e=>setFormData({...formData, parentPhone:e.target.value})}/>
</div>
<input className="w-full border p-2 rounded" placeholder="家庭住址" value={formData.address} onChange={e=>setFormData({...formData, address:e.target.value})}/>
</div>
<button type="submit" disabled={submitting} className="w-full bg-blue-600 text-white py-2 rounded mt-4">{submitting?'提交中':'保存'}</button>
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded mt-2">取消</button>
</form>
</div>
</div>
)}
{isTransferOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in zoom-in-95">
<h3 className="font-bold text-lg mb-4">学生调班</h3>
<p className="text-sm text-gray-500 mb-4">将选中的学生移动到新的班级。该学生在当前班级的未核销奖励可能会保留,但建议先核销。</p>
<select className="w-full border p-2 rounded mb-4" value={transferTargetClass} onChange={e=>setTransferTargetClass(e.target.value)}>
<option value="">-- 选择目标班级 --</option>
{classList.map(c => <option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>)}
</select>
<div className="flex gap-2">
<button onClick={submitTransfer} disabled={submitting || !transferTargetClass} className="flex-1 bg-amber-500 text-white py-2 rounded font-bold hover:bg-amber-600 disabled:opacity-50">确认调班</button>
<button onClick={()=>setIsTransferOpen(false)} className="flex-1 border py-2 rounded hover:bg-gray-50">取消</button>
</div>
</div>
</div>
)}
{isImportOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl p-6 w-full max-w-md relative">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-lg text-gray-900">Excel 批量导入</h3>
<button onClick={()=>setIsImportOpen(false)} className="text-gray-400 hover:text-gray-600"><X size={20}/></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">选择归属班级</label>
<select className="w-full border border-gray-300 p-2 rounded text-sm text-gray-900" value={importTargetClass} onChange={e => setImportTargetClass(e.target.value)}>
<option value="">-- 请选择 --</option>
{classList.map(c => {
if(currentUser?.role === 'TEACHER' && currentUser.homeroomClass && c.grade+c.className !== currentUser.homeroomClass) return null;
return <option key={c._id} value={c.grade+c.className}>{c.grade}{c.className}</option>
})}
</select>
</div>
<div className="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:bg-gray-50 transition-colors">
<Upload className="mx-auto h-10 w-10 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">点击上传 .xlsx 文件</p>
<p className="text-xs text-gray-400 mt-1">支持列名:座号(学号), 姓名, 家长姓名, 家长电话, 地址</p>
<p className="text-xs text-gray-400">注意: 导入的"学号"列将作为座号</p>
<input type="file" accept=".xlsx, .xls" className="opacity-0 absolute inset-0 cursor-pointer h-full w-full z-10"
// @ts-ignore
onChange={e => setImportFile(e.target.files?.[0])}
/>
</div>
{importFile && <div className="text-sm text-blue-600 font-medium bg-blue-50 p-2 rounded border border-blue-100">已选择: {importFile.name}</div>}
<button onClick={handleExcelImport} disabled={!importFile || submitting} className="w-full bg-emerald-600 text-white py-2 rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors relative z-20">
{submitting ? '导入中...' : '开始导入'}
</button>
</div>
</div>
</div>
)}
</div>
);
};