Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| }; | |