Spaces:
Sleeping
Sleeping
Upload 27 files
Browse files- App.tsx +27 -14
- components/Sidebar.tsx +4 -2
- metadata.json +1 -1
- pages/Dashboard.tsx +96 -74
- pages/Reports.tsx +10 -3
- pages/ScoreList.tsx +12 -2
- pages/StudentList.tsx +68 -32
- pages/SubjectList.tsx +69 -14
App.tsx
CHANGED
|
@@ -1,21 +1,23 @@
|
|
| 1 |
|
| 2 |
-
import React, { useState, useEffect } from 'react';
|
| 3 |
import { Sidebar } from './components/Sidebar';
|
| 4 |
import { Header } from './components/Header';
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
import { Login } from './pages/Login';
|
| 16 |
import { User, UserRole } from './types';
|
| 17 |
import { api } from './services/api';
|
| 18 |
-
import { AlertTriangle } from 'lucide-react';
|
| 19 |
|
| 20 |
// Error Boundary Component
|
| 21 |
class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: Error | null}> {
|
|
@@ -68,6 +70,15 @@ class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasErr
|
|
| 68 |
}
|
| 69 |
}
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
const AppContent: React.FC = () => {
|
| 72 |
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 73 |
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
@@ -108,7 +119,7 @@ const AppContent: React.FC = () => {
|
|
| 108 |
case 'reports': return <Reports />;
|
| 109 |
case 'subjects': return <SubjectList />;
|
| 110 |
case 'users': return <UserList />;
|
| 111 |
-
case 'schools': return <SchoolList />;
|
| 112 |
default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
|
| 113 |
}
|
| 114 |
};
|
|
@@ -149,7 +160,9 @@ const AppContent: React.FC = () => {
|
|
| 149 |
/>
|
| 150 |
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-4 md:p-6 w-full">
|
| 151 |
<div className="max-w-7xl mx-auto w-full">
|
| 152 |
-
{
|
|
|
|
|
|
|
| 153 |
</div>
|
| 154 |
</main>
|
| 155 |
</div>
|
|
|
|
| 1 |
|
| 2 |
+
import React, { useState, useEffect, Suspense } from 'react';
|
| 3 |
import { Sidebar } from './components/Sidebar';
|
| 4 |
import { Header } from './components/Header';
|
| 5 |
+
// Lazy load pages for performance optimization
|
| 6 |
+
const Dashboard = React.lazy(() => import('./pages/Dashboard').then(module => ({ default: module.Dashboard })));
|
| 7 |
+
const StudentList = React.lazy(() => import('./pages/StudentList').then(module => ({ default: module.StudentList })));
|
| 8 |
+
const CourseList = React.lazy(() => import('./pages/CourseList').then(module => ({ default: module.CourseList })));
|
| 9 |
+
const ScoreList = React.lazy(() => import('./pages/ScoreList').then(module => ({ default: module.ScoreList })));
|
| 10 |
+
const ClassList = React.lazy(() => import('./pages/ClassList').then(module => ({ default: module.ClassList })));
|
| 11 |
+
const Settings = React.lazy(() => import('./pages/Settings').then(module => ({ default: module.Settings })));
|
| 12 |
+
const Reports = React.lazy(() => import('./pages/Reports').then(module => ({ default: module.Reports })));
|
| 13 |
+
const SubjectList = React.lazy(() => import('./pages/SubjectList').then(module => ({ default: module.SubjectList })));
|
| 14 |
+
const UserList = React.lazy(() => import('./pages/UserList').then(module => ({ default: module.UserList })));
|
| 15 |
+
const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
|
| 16 |
+
|
| 17 |
import { Login } from './pages/Login';
|
| 18 |
import { User, UserRole } from './types';
|
| 19 |
import { api } from './services/api';
|
| 20 |
+
import { AlertTriangle, Loader2 } from 'lucide-react';
|
| 21 |
|
| 22 |
// Error Boundary Component
|
| 23 |
class ErrorBoundary extends React.Component<{children: React.ReactNode}, {hasError: boolean, error: Error | null}> {
|
|
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
| 73 |
+
const PageLoading = () => (
|
| 74 |
+
<div className="flex h-full w-full items-center justify-center min-h-[400px]">
|
| 75 |
+
<div className="flex flex-col items-center space-y-3">
|
| 76 |
+
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
| 77 |
+
<p className="text-sm text-gray-400">资源加载中...</p>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
const AppContent: React.FC = () => {
|
| 83 |
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 84 |
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
|
|
| 119 |
case 'reports': return <Reports />;
|
| 120 |
case 'subjects': return <SubjectList />;
|
| 121 |
case 'users': return <UserList />;
|
| 122 |
+
case 'schools': return <SchoolList />;
|
| 123 |
default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
|
| 124 |
}
|
| 125 |
};
|
|
|
|
| 160 |
/>
|
| 161 |
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-4 md:p-6 w-full">
|
| 162 |
<div className="max-w-7xl mx-auto w-full">
|
| 163 |
+
<Suspense fallback={<PageLoading />}>
|
| 164 |
+
{renderContent()}
|
| 165 |
+
</Suspense>
|
| 166 |
</div>
|
| 167 |
</main>
|
| 168 |
</div>
|
components/Sidebar.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building } from 'lucide-react';
|
| 3 |
import { UserRole } from '../types';
|
|
@@ -21,7 +22,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
|
|
| 21 |
{ id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
|
| 22 |
// Update: Allow TEACHER to access reports
|
| 23 |
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 24 |
-
|
|
|
|
| 25 |
{ id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN] },
|
| 26 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
|
| 27 |
];
|
|
@@ -80,4 +82,4 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
|
|
| 80 |
</div>
|
| 81 |
</>
|
| 82 |
);
|
| 83 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React from 'react';
|
| 3 |
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
|
|
|
| 22 |
{ id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.TEACHER, UserRole.STUDENT] },
|
| 23 |
// Update: Allow TEACHER to access reports
|
| 24 |
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 25 |
+
// Update: Allow TEACHER to access subjects (limited view)
|
| 26 |
+
{ id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.TEACHER] },
|
| 27 |
{ id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN] },
|
| 28 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN] },
|
| 29 |
];
|
|
|
|
| 82 |
</div>
|
| 83 |
</>
|
| 84 |
);
|
| 85 |
+
};
|
metadata.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "智慧校园管理系统",
|
| 3 |
"description": "一个综合性的学生管理系统仪表板,具有基于角色的访问控制、学生档案、课程管理和绩效分析功能。",
|
| 4 |
"requestFramePermissions": []
|
| 5 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "Copy of 智慧校园管理系统",
|
| 3 |
"description": "一个综合性的学生管理系统仪表板,具有基于角色的访问控制、学生档案、课程管理和绩效分析功能。",
|
| 4 |
"requestFramePermissions": []
|
| 5 |
}
|
pages/Dashboard.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
| 2 |
import { Users, BookOpen, GraduationCap, TrendingUp, AlertTriangle, ArrowRight, Activity, Calendar, X, CheckCircle, Plus, Trash2 } from 'lucide-react';
|
| 3 |
import { api } from '../services/api';
|
|
@@ -8,10 +9,23 @@ interface DashboardProps {
|
|
| 8 |
onNavigate: (view: string) => void;
|
| 9 |
}
|
| 10 |
|
| 11 |
-
//
|
| 12 |
-
const gradeOrder: Record<string, number> = {
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
| 17 |
const [stats, setStats] = useState({ studentCount: 0, courseCount: 0, avgScore: 0, excellentRate: '0%' });
|
|
@@ -28,10 +42,8 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 28 |
|
| 29 |
// 课表视图状态
|
| 30 |
const [viewGrade, setViewGrade] = useState(''); // 管理员年级视角
|
| 31 |
-
// 注意:viewClass 状态移除,改为在排课弹窗中临时选择
|
| 32 |
|
| 33 |
const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
|
| 34 |
-
// 排课表单增加 className 字段
|
| 35 |
const [editForm, setEditForm] = useState({ className: '', subject: '', teacherName: '' });
|
| 36 |
|
| 37 |
// Chart Data
|
|
@@ -39,6 +51,7 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 39 |
|
| 40 |
const currentUser = api.auth.getCurrentUser();
|
| 41 |
const isAdmin = currentUser?.role === 'ADMIN';
|
|
|
|
| 42 |
|
| 43 |
useEffect(() => {
|
| 44 |
const loadData = async () => {
|
|
@@ -58,7 +71,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 58 |
|
| 59 |
// 如果是管理员,且没有选年级,默认选中第一个有数据的年级
|
| 60 |
if (isAdmin && classes.length > 0) {
|
| 61 |
-
// Explicitly cast mapped array to string[] to fix TS error
|
| 62 |
const grades = Array.from(new Set((classes as ClassInfo[]).map((c: ClassInfo) => c.grade))).sort(sortGrades);
|
| 63 |
if (grades.length > 0) {
|
| 64 |
setViewGrade(grades[0] as string);
|
|
@@ -68,7 +80,6 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 68 |
// Generate Warnings (Simplified)
|
| 69 |
const newWarnings: string[] = [];
|
| 70 |
subs.forEach((sub: Subject) => {
|
| 71 |
-
// Explicitly cast scores
|
| 72 |
const subScores = (scores as Score[]).filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
|
| 73 |
if (subScores.length > 0) {
|
| 74 |
const avg = subScores.reduce((a: number, b: Score) => a + b.score, 0) / subScores.length;
|
|
@@ -119,19 +130,24 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 119 |
} catch(e) { console.error(e); }
|
| 120 |
};
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
const handleSaveSchedule = async () => {
|
| 123 |
if (!editingCell) return;
|
| 124 |
|
| 125 |
// 校验
|
| 126 |
-
if (
|
| 127 |
if (!editForm.subject) return alert('请选择科目');
|
| 128 |
if (!editForm.teacherName) return alert('请选择任课教师');
|
| 129 |
|
| 130 |
-
//
|
| 131 |
-
// 但为了兼容管理员排班逻辑,这里主要处理管理员
|
| 132 |
-
|
| 133 |
-
// 构造班级全名:如果是管理员模式,editForm.className 只是 "(1)班",需要拼上年级 viewGrade
|
| 134 |
-
// 但为了方便,下拉框可以直接存全名 "六年级(1)班"
|
| 135 |
const targetClassName = editForm.className;
|
| 136 |
|
| 137 |
await api.schedules.save({
|
|
@@ -156,14 +172,15 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 156 |
fetchSchedules();
|
| 157 |
};
|
| 158 |
|
| 159 |
-
// 排序后的年级选项
|
| 160 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(sortGrades);
|
| 161 |
|
| 162 |
-
//
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
| 167 |
|
| 168 |
const cards = [
|
| 169 |
{ label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '+12%' },
|
|
@@ -280,7 +297,7 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 280 |
</div>
|
| 281 |
)}
|
| 282 |
|
| 283 |
-
{!isAdmin &&
|
| 284 |
<div className="text-sm text-gray-500">
|
| 285 |
查看: <span className="font-bold text-gray-800">{currentUser.trueName || currentUser.username}</span> 的课表
|
| 286 |
</div>
|
|
@@ -303,8 +320,9 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 303 |
<td className="p-4 border-b border-r font-bold text-gray-400 bg-gray-50/50">第{period}节</td>
|
| 304 |
{[1,2,3,4,5].map(day => {
|
| 305 |
const slotItems = schedules.filter(s => s.dayOfWeek === day && s.period === period);
|
| 306 |
-
//
|
| 307 |
-
|
|
|
|
| 308 |
|
| 309 |
return (
|
| 310 |
<td
|
|
@@ -312,43 +330,39 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 312 |
className="p-2 border-b h-28 align-top transition-colors relative group"
|
| 313 |
>
|
| 314 |
<div className="flex flex-wrap gap-2 content-start h-full overflow-y-auto custom-scrollbar pb-6">
|
| 315 |
-
{slotItems.map(item =>
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
<
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
</div>
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
<span className="bg-blue-50 text-blue-600 px-1.5 rounded font-mono text-[10px] font-bold">
|
| 333 |
-
{item.className.replace(viewGrade, '')}
|
| 334 |
-
</span>
|
| 335 |
-
<span className="text-[10px] truncate max-w-[50%]">{item.teacherName}</span>
|
| 336 |
-
</div>
|
| 337 |
-
</div>
|
| 338 |
-
))}
|
| 339 |
|
| 340 |
-
{/* Add Button
|
| 341 |
{canAdd && (
|
| 342 |
<div className="absolute bottom-1 right-1 left-1 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
| 343 |
<button
|
| 344 |
-
onClick={() =>
|
| 345 |
-
setEditingCell({ day, period });
|
| 346 |
-
setEditForm({
|
| 347 |
-
className: '', // 需要在弹窗选
|
| 348 |
-
subject: '',
|
| 349 |
-
teacherName: ''
|
| 350 |
-
});
|
| 351 |
-
}}
|
| 352 |
className="w-full bg-blue-50 border border-blue-200 text-blue-500 rounded py-1 hover:bg-blue-100 flex items-center justify-center shadow-sm"
|
| 353 |
>
|
| 354 |
<Plus size={14}/>
|
|
@@ -371,28 +385,31 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 371 |
<div className="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px] z-50">
|
| 372 |
<div className="bg-white p-6 rounded-xl shadow-2xl w-80 animate-in zoom-in-95 border border-gray-100">
|
| 373 |
<h4 className="font-bold mb-4 text-gray-800 border-b pb-2">
|
| 374 |
-
|
| 375 |
-
<span className="text-sm font-normal text-gray-500"
|
| 376 |
</h4>
|
| 377 |
<div className="space-y-4">
|
| 378 |
-
{/*
|
| 379 |
-
|
| 380 |
-
<
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
>
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
</div>
|
| 391 |
-
)}
|
| 392 |
|
| 393 |
<div>
|
| 394 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">科目</label>
|
| 395 |
-
<select
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
<option value="">-- 请选择科目 --</option>
|
| 397 |
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 398 |
</select>
|
|
@@ -400,7 +417,12 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 400 |
|
| 401 |
<div>
|
| 402 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">任课教师</label>
|
| 403 |
-
<select
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
<option value="">-- 请选择教师 --</option>
|
| 405 |
{teachers.map(t=><option key={t._id} value={t.trueName || t.username}>{t.trueName} ({t.teachingSubject || '无科目'})</option>)}
|
| 406 |
</select>
|
|
@@ -433,4 +455,4 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 433 |
)}
|
| 434 |
</div>
|
| 435 |
);
|
| 436 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useEffect, useState } from 'react';
|
| 3 |
import { Users, BookOpen, GraduationCap, TrendingUp, AlertTriangle, ArrowRight, Activity, Calendar, X, CheckCircle, Plus, Trash2 } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
|
|
|
| 9 |
onNavigate: (view: string) => void;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
// 优化后的年级排序:支持 K12 全学段中文习惯排序
|
| 13 |
+
export const gradeOrder: Record<string, number> = {
|
| 14 |
+
'一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6,
|
| 15 |
+
'初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9,
|
| 16 |
+
'高一': 10, '高二': 11, '高三': 12
|
| 17 |
+
};
|
| 18 |
+
export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
|
| 19 |
+
export const sortClasses = (a: string, b: string) => {
|
| 20 |
+
// 提取年级部分
|
| 21 |
+
const getGrade = (s: string) => Object.keys(gradeOrder).find(g => s.startsWith(g)) || '';
|
| 22 |
+
const gradeA = getGrade(a);
|
| 23 |
+
const gradeB = getGrade(b);
|
| 24 |
+
if (gradeA !== gradeB) return sortGrades(gradeA, gradeB);
|
| 25 |
+
// 年级相同,比较班级号 (假设格式如 (1)班, 1班)
|
| 26 |
+
const getNum = (s: string) => parseInt(s.replace(/[^0-9]/g, '')) || 0;
|
| 27 |
+
return getNum(a) - getNum(b);
|
| 28 |
+
};
|
| 29 |
|
| 30 |
export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
| 31 |
const [stats, setStats] = useState({ studentCount: 0, courseCount: 0, avgScore: 0, excellentRate: '0%' });
|
|
|
|
| 42 |
|
| 43 |
// 课表视图状态
|
| 44 |
const [viewGrade, setViewGrade] = useState(''); // 管理员年级视角
|
|
|
|
| 45 |
|
| 46 |
const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
|
|
|
|
| 47 |
const [editForm, setEditForm] = useState({ className: '', subject: '', teacherName: '' });
|
| 48 |
|
| 49 |
// Chart Data
|
|
|
|
| 51 |
|
| 52 |
const currentUser = api.auth.getCurrentUser();
|
| 53 |
const isAdmin = currentUser?.role === 'ADMIN';
|
| 54 |
+
const isTeacher = currentUser?.role === 'TEACHER';
|
| 55 |
|
| 56 |
useEffect(() => {
|
| 57 |
const loadData = async () => {
|
|
|
|
| 71 |
|
| 72 |
// 如果是管理员,且没有选年级,默认选中第一个有数据的年级
|
| 73 |
if (isAdmin && classes.length > 0) {
|
|
|
|
| 74 |
const grades = Array.from(new Set((classes as ClassInfo[]).map((c: ClassInfo) => c.grade))).sort(sortGrades);
|
| 75 |
if (grades.length > 0) {
|
| 76 |
setViewGrade(grades[0] as string);
|
|
|
|
| 80 |
// Generate Warnings (Simplified)
|
| 81 |
const newWarnings: string[] = [];
|
| 82 |
subs.forEach((sub: Subject) => {
|
|
|
|
| 83 |
const subScores = (scores as Score[]).filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
|
| 84 |
if (subScores.length > 0) {
|
| 85 |
const avg = subScores.reduce((a: number, b: Score) => a + b.score, 0) / subScores.length;
|
|
|
|
| 130 |
} catch(e) { console.error(e); }
|
| 131 |
};
|
| 132 |
|
| 133 |
+
const handleOpenAddModal = (day: number, period: number) => {
|
| 134 |
+
setEditingCell({ day, period });
|
| 135 |
+
setEditForm({
|
| 136 |
+
className: '', // 需选择
|
| 137 |
+
subject: isTeacher ? (currentUser?.teachingSubject || '') : '', // 老师自动填科目
|
| 138 |
+
teacherName: isTeacher ? (currentUser?.trueName || currentUser?.username || '') : '' // 老师自动填名字
|
| 139 |
+
});
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
const handleSaveSchedule = async () => {
|
| 143 |
if (!editingCell) return;
|
| 144 |
|
| 145 |
// 校验
|
| 146 |
+
if (!editForm.className) return alert('请选择班级');
|
| 147 |
if (!editForm.subject) return alert('请选择科目');
|
| 148 |
if (!editForm.teacherName) return alert('请选择任课教师');
|
| 149 |
|
| 150 |
+
// 构造班级全名
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
const targetClassName = editForm.className;
|
| 152 |
|
| 153 |
await api.schedules.save({
|
|
|
|
| 172 |
fetchSchedules();
|
| 173 |
};
|
| 174 |
|
| 175 |
+
// 排序后的年级选项 (Admin Only)
|
| 176 |
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(sortGrades);
|
| 177 |
|
| 178 |
+
// 弹窗中可选的班级列表:
|
| 179 |
+
// 1. 如果是管理员,显示当前选中年级 viewGrade 的班级 (保持上下文一致)
|
| 180 |
+
// 2. 如果是老师,显示全校所有班级 (因为老师可能跨年级授课)
|
| 181 |
+
const modalClassOptions = isAdmin
|
| 182 |
+
? classList.filter(c => c.grade === viewGrade).map(c => c.grade + c.className).sort(sortClasses)
|
| 183 |
+
: classList.map(c => c.grade + c.className).sort(sortClasses);
|
| 184 |
|
| 185 |
const cards = [
|
| 186 |
{ label: '在校学生', value: stats.studentCount, icon: Users, color: 'bg-blue-500', trend: '+12%' },
|
|
|
|
| 297 |
</div>
|
| 298 |
)}
|
| 299 |
|
| 300 |
+
{!isAdmin && isTeacher && (
|
| 301 |
<div className="text-sm text-gray-500">
|
| 302 |
查看: <span className="font-bold text-gray-800">{currentUser.trueName || currentUser.username}</span> 的课表
|
| 303 |
</div>
|
|
|
|
| 320 |
<td className="p-4 border-b border-r font-bold text-gray-400 bg-gray-50/50">第{period}节</td>
|
| 321 |
{[1,2,3,4,5].map(day => {
|
| 322 |
const slotItems = schedules.filter(s => s.dayOfWeek === day && s.period === period);
|
| 323 |
+
// Update: Admins OR Teachers can add/edit
|
| 324 |
+
// Teacher can add slots for themselves
|
| 325 |
+
const canAdd = isAdmin || isTeacher;
|
| 326 |
|
| 327 |
return (
|
| 328 |
<td
|
|
|
|
| 330 |
className="p-2 border-b h-28 align-top transition-colors relative group"
|
| 331 |
>
|
| 332 |
<div className="flex flex-wrap gap-2 content-start h-full overflow-y-auto custom-scrollbar pb-6">
|
| 333 |
+
{slotItems.map(item => {
|
| 334 |
+
// Teacher can only delete their own lessons
|
| 335 |
+
const canDelete = isAdmin || (isTeacher && (item.teacherName === currentUser.trueName || item.teacherName === currentUser.username));
|
| 336 |
+
return (
|
| 337 |
+
<div key={item._id} className="text-xs text-left bg-white border border-blue-200 rounded-md p-2 w-full shadow-sm relative group/item hover:border-blue-400 hover:shadow-md transition-all">
|
| 338 |
+
<div className="font-bold text-blue-700 flex justify-between items-center mb-1">
|
| 339 |
+
<span className="truncate mr-1">{item.subject}</span>
|
| 340 |
+
{canDelete && (
|
| 341 |
+
<button
|
| 342 |
+
onClick={(e)=>{ e.stopPropagation(); handleDeleteSchedule(item); }}
|
| 343 |
+
className="text-gray-300 hover:text-red-500 transition-colors p-0.5"
|
| 344 |
+
title="删除课程"
|
| 345 |
+
>
|
| 346 |
+
<X size={14}/>
|
| 347 |
+
</button>
|
| 348 |
+
)}
|
| 349 |
+
</div>
|
| 350 |
+
<div className="flex justify-between items-center text-gray-600">
|
| 351 |
+
{/* 显示班级名 */}
|
| 352 |
+
<span className="bg-blue-50 text-blue-600 px-1.5 rounded font-mono text-[10px] font-bold">
|
| 353 |
+
{item.className.replace(viewGrade, '')}
|
| 354 |
+
</span>
|
| 355 |
+
<span className="text-[10px] truncate max-w-[50%]">{item.teacherName}</span>
|
| 356 |
+
</div>
|
| 357 |
</div>
|
| 358 |
+
);
|
| 359 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
|
| 361 |
+
{/* Add Button */}
|
| 362 |
{canAdd && (
|
| 363 |
<div className="absolute bottom-1 right-1 left-1 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
| 364 |
<button
|
| 365 |
+
onClick={() => handleOpenAddModal(day, period)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
className="w-full bg-blue-50 border border-blue-200 text-blue-500 rounded py-1 hover:bg-blue-100 flex items-center justify-center shadow-sm"
|
| 367 |
>
|
| 368 |
<Plus size={14}/>
|
|
|
|
| 385 |
<div className="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px] z-50">
|
| 386 |
<div className="bg-white p-6 rounded-xl shadow-2xl w-80 animate-in zoom-in-95 border border-gray-100">
|
| 387 |
<h4 className="font-bold mb-4 text-gray-800 border-b pb-2">
|
| 388 |
+
{isTeacher ? '我的排课' : '排课管理'} <br/>
|
| 389 |
+
<span className="text-sm font-normal text-gray-500">周{['一','二','三','四','五'][editingCell.day-1]} 第{editingCell.period}节</span>
|
| 390 |
</h4>
|
| 391 |
<div className="space-y-4">
|
| 392 |
+
{/* 班级选择: Admin 选择 viewGrade 下的班级,Teacher 可以选全校班级 */}
|
| 393 |
+
<div>
|
| 394 |
+
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">班级</label>
|
| 395 |
+
<select
|
| 396 |
+
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
| 397 |
+
value={editForm.className}
|
| 398 |
+
onChange={e=>setEditForm({...editForm, className: e.target.value})}
|
| 399 |
+
>
|
| 400 |
+
<option value="">-- 请选择班级 --</option>
|
| 401 |
+
{modalClassOptions.map(c => <option key={c} value={c}>{c}</option>)}
|
| 402 |
+
</select>
|
| 403 |
+
</div>
|
|
|
|
|
|
|
| 404 |
|
| 405 |
<div>
|
| 406 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">科目</label>
|
| 407 |
+
<select
|
| 408 |
+
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none disabled:bg-gray-100 disabled:text-gray-500"
|
| 409 |
+
value={editForm.subject}
|
| 410 |
+
onChange={e=>setEditForm({...editForm, subject: e.target.value})}
|
| 411 |
+
disabled={isTeacher && !!currentUser?.teachingSubject} // 老师如果有任教科目则锁定
|
| 412 |
+
>
|
| 413 |
<option value="">-- 请选择科目 --</option>
|
| 414 |
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 415 |
</select>
|
|
|
|
| 417 |
|
| 418 |
<div>
|
| 419 |
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">任课教师</label>
|
| 420 |
+
<select
|
| 421 |
+
className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none disabled:bg-gray-100 disabled:text-gray-500"
|
| 422 |
+
value={editForm.teacherName}
|
| 423 |
+
onChange={e=>setEditForm({...editForm, teacherName: e.target.value})}
|
| 424 |
+
disabled={isTeacher} // 老师只能给自己排课
|
| 425 |
+
>
|
| 426 |
<option value="">-- 请选择教师 --</option>
|
| 427 |
{teachers.map(t=><option key={t._id} value={t.trueName || t.username}>{t.trueName} ({t.teachingSubject || '无科目'})</option>)}
|
| 428 |
</select>
|
|
|
|
| 455 |
)}
|
| 456 |
</div>
|
| 457 |
);
|
| 458 |
+
};
|
pages/Reports.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import {
|
| 3 |
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
|
@@ -7,6 +8,12 @@ import { api } from '../services/api';
|
|
| 7 |
import { Loader2, Download, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon } from 'lucide-react';
|
| 8 |
import { Score, Student, ClassInfo, Subject, Exam } from '../types';
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
export const Reports: React.FC = () => {
|
| 11 |
const [loading, setLoading] = useState(true);
|
| 12 |
const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('overview');
|
|
@@ -80,7 +87,7 @@ export const Reports: React.FC = () => {
|
|
| 80 |
const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
|
| 81 |
|
| 82 |
// Grade Ladder (Avg score by grade)
|
| 83 |
-
const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort();
|
| 84 |
const ladderData = uniqueGradesList.map(g => {
|
| 85 |
const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
|
| 86 |
const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
|
|
@@ -258,7 +265,7 @@ export const Reports: React.FC = () => {
|
|
| 258 |
};
|
| 259 |
};
|
| 260 |
|
| 261 |
-
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort();
|
| 262 |
const allClasses = classes.map(c => c.grade + c.className);
|
| 263 |
|
| 264 |
// Student List for Focus Tab
|
|
@@ -604,4 +611,4 @@ export const Reports: React.FC = () => {
|
|
| 604 |
)}
|
| 605 |
</div>
|
| 606 |
);
|
| 607 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import {
|
| 4 |
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
|
|
|
| 8 |
import { Loader2, Download, Filter, TrendingUp, Grid, BarChart2, User, PieChart as PieChartIcon } from 'lucide-react';
|
| 9 |
import { Score, Student, ClassInfo, Subject, Exam } from '../types';
|
| 10 |
|
| 11 |
+
// Reuse sort logic
|
| 12 |
+
const localSortGrades = (a: string, b: string) => {
|
| 13 |
+
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 14 |
+
return (order[a] || 99) - (order[b] || 99);
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
export const Reports: React.FC = () => {
|
| 18 |
const [loading, setLoading] = useState(true);
|
| 19 |
const [activeTab, setActiveTab] = useState<'overview' | 'grade' | 'trend' | 'matrix' | 'student'>('overview');
|
|
|
|
| 87 |
const passRate = normalScores.length ? (totalPass / normalScores.length)*100 : 0;
|
| 88 |
|
| 89 |
// Grade Ladder (Avg score by grade)
|
| 90 |
+
const uniqueGradesList = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 91 |
const ladderData = uniqueGradesList.map(g => {
|
| 92 |
const gradeClasses = classes.filter(c => c.grade === g).map(c => c.grade+c.className);
|
| 93 |
const gradeStus = students.filter(s => gradeClasses.includes(s.className)).map(s => s.studentNo);
|
|
|
|
| 265 |
};
|
| 266 |
};
|
| 267 |
|
| 268 |
+
const uniqueGrades = Array.from(new Set(classes.map(c => c.grade))).sort(localSortGrades);
|
| 269 |
const allClasses = classes.map(c => c.grade + c.className);
|
| 270 |
|
| 271 |
// Student List for Focus Tab
|
|
|
|
| 611 |
)}
|
| 612 |
</div>
|
| 613 |
);
|
| 614 |
+
};
|
pages/ScoreList.tsx
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { api } from '../services/api';
|
| 3 |
import { Score, Student, Subject, ClassInfo, ExamStatus, Exam, SystemConfig } from '../types';
|
| 4 |
import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar } from 'lucide-react';
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
export const ScoreList: React.FC = () => {
|
| 7 |
// ... state ...
|
| 8 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
@@ -178,7 +185,10 @@ export const ScoreList: React.FC = () => {
|
|
| 178 |
|
| 179 |
const handleUpdateExamDate = async (name: string, date: string) => { await api.exams.save({ name, date }); loadData(); };
|
| 180 |
const toggleSelect = (id: string) => { const newSet = new Set(selectedIds); if (newSet.has(id)) newSet.delete(id); else newSet.add(id); setSelectedIds(newSet); };
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 183 |
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 184 |
const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
|
@@ -405,4 +415,4 @@ export const ScoreList: React.FC = () => {
|
|
| 405 |
)}
|
| 406 |
</div>
|
| 407 |
);
|
| 408 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Score, Student, Subject, ClassInfo, ExamStatus, Exam, SystemConfig } from '../types';
|
| 5 |
import { Loader2, Plus, Trash2, Award, FileSpreadsheet, X, Upload, Edit, Save, Calendar } from 'lucide-react';
|
| 6 |
|
| 7 |
+
// Reuse sort helper
|
| 8 |
+
const localSortGrades = (a: string, b: string) => {
|
| 9 |
+
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 10 |
+
return (order[a] || 99) - (order[b] || 99);
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
export const ScoreList: React.FC = () => {
|
| 14 |
// ... state ...
|
| 15 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
|
|
| 185 |
|
| 186 |
const handleUpdateExamDate = async (name: string, date: string) => { await api.exams.save({ name, date }); loadData(); };
|
| 187 |
const toggleSelect = (id: string) => { const newSet = new Set(selectedIds); if (newSet.has(id)) newSet.delete(id); else newSet.add(id); setSelectedIds(newSet); };
|
| 188 |
+
|
| 189 |
+
// SORTED Grades
|
| 190 |
+
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(localSortGrades);
|
| 191 |
+
|
| 192 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 193 |
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 194 |
const uniqueExamNames = Array.from(new Set(scores.map(s => s.examName || s.type)));
|
|
|
|
| 415 |
)}
|
| 416 |
</div>
|
| 417 |
);
|
| 418 |
+
};
|
pages/StudentList.tsx
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User, FileSpreadsheet } from 'lucide-react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Student, ClassInfo } from '../types';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export const StudentList: React.FC = () => {
|
| 7 |
-
// ... (你的 state 定义保持不变) ...
|
| 8 |
const [searchTerm, setSearchTerm] = useState('');
|
| 9 |
const [students, setStudents] = useState<Student[]>([]);
|
| 10 |
const [classList, setClassList] = useState<ClassInfo[]>([]);
|
|
@@ -15,6 +22,9 @@ export const StudentList: React.FC = () => {
|
|
| 15 |
const [submitting, setSubmitting] = useState(false);
|
| 16 |
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
// Filters
|
| 19 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
| 20 |
const [selectedClass, setSelectedClass] = useState('All');
|
|
@@ -24,7 +34,7 @@ export const StudentList: React.FC = () => {
|
|
| 24 |
const [importTargetClass, setImportTargetClass] = useState('');
|
| 25 |
|
| 26 |
// Form State
|
| 27 |
-
const
|
| 28 |
name: '',
|
| 29 |
studentNo: '',
|
| 30 |
gender: 'Male',
|
|
@@ -34,7 +44,8 @@ export const StudentList: React.FC = () => {
|
|
| 34 |
parentName: '',
|
| 35 |
parentPhone: '',
|
| 36 |
address: ''
|
| 37 |
-
}
|
|
|
|
| 38 |
|
| 39 |
const currentUser = api.auth.getCurrentUser();
|
| 40 |
|
|
@@ -56,19 +67,14 @@ export const StudentList: React.FC = () => {
|
|
| 56 |
|
| 57 |
useEffect(() => { loadData(); }, []);
|
| 58 |
|
| 59 |
-
// --------------- 修复点开始 ---------------
|
| 60 |
-
// 这里删除了原来的 return false; };
|
| 61 |
-
// 并添加了 hasPermission 函数定义
|
| 62 |
const hasPermission = (className: string) => {
|
| 63 |
if (!currentUser) return false;
|
| 64 |
if (currentUser.role === 'ADMIN') return true;
|
| 65 |
-
// 如果是老师,检查是否是班主任
|
| 66 |
if (currentUser.role === 'TEACHER') {
|
| 67 |
return currentUser.homeroomClass === className;
|
| 68 |
}
|
| 69 |
return false;
|
| 70 |
};
|
| 71 |
-
// --------------- 修复点结束 ---------------
|
| 72 |
|
| 73 |
const handleDelete = async (id: string, className: string) => {
|
| 74 |
if (!hasPermission(className)) return alert('您没有权限操作此班级学生');
|
|
@@ -102,19 +108,48 @@ export const StudentList: React.FC = () => {
|
|
| 102 |
}
|
| 103 |
};
|
| 104 |
|
| 105 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
e.preventDefault();
|
| 107 |
setSubmitting(true);
|
| 108 |
try {
|
| 109 |
-
|
| 110 |
...formData,
|
| 111 |
-
birthday: '2015-01-01',
|
| 112 |
status: 'Enrolled',
|
| 113 |
gender: formData.gender as any
|
| 114 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
setIsModalOpen(false);
|
| 116 |
loadData();
|
| 117 |
-
} catch (error) { alert('添加失败'); }
|
| 118 |
finally { setSubmitting(false); }
|
| 119 |
};
|
| 120 |
|
|
@@ -181,7 +216,6 @@ export const StudentList: React.FC = () => {
|
|
| 181 |
|
| 182 |
const targetClassName = importTargetClass || getVal(['班级', 'Class'], 99) || '未分配';
|
| 183 |
|
| 184 |
-
// 使用我们修复好的 hasPermission 函数
|
| 185 |
if (!hasPermission(targetClassName)) {
|
| 186 |
console.warn('Skipping import for class due to permission:', targetClassName);
|
| 187 |
continue;
|
|
@@ -221,9 +255,9 @@ export const StudentList: React.FC = () => {
|
|
| 221 |
return matchesSearch && matchesGrade && matchesClass;
|
| 222 |
});
|
| 223 |
|
| 224 |
-
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort();
|
| 225 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 226 |
-
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort();
|
| 227 |
|
| 228 |
const canGlobalAdd = currentUser?.role === 'ADMIN' || (currentUser?.role === 'TEACHER' && currentUser.homeroomClass);
|
| 229 |
|
|
@@ -242,7 +276,7 @@ export const StudentList: React.FC = () => {
|
|
| 242 |
<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">
|
| 243 |
<FileSpreadsheet size={16}/><span>Excel导入</span>
|
| 244 |
</button>
|
| 245 |
-
<button onClick={
|
| 246 |
<Plus size={16}/><span>新增</span>
|
| 247 |
</button>
|
| 248 |
</div>
|
|
@@ -276,13 +310,17 @@ export const StudentList: React.FC = () => {
|
|
| 276 |
<th className="px-6 py-3">基本信息</th>
|
| 277 |
<th className="px-6 py-3">学号</th>
|
| 278 |
<th className="px-6 py-3">班级</th>
|
|
|
|
|
|
|
| 279 |
</tr>
|
| 280 |
</thead>
|
| 281 |
<tbody className="divide-y divide-gray-100">
|
| 282 |
-
{filteredStudents.length > 0 ? filteredStudents.map(s =>
|
|
|
|
|
|
|
| 283 |
<tr key={s._id || s.id} className="hover:bg-blue-50/30 transition-colors">
|
| 284 |
<td className="px-6 py-4">
|
| 285 |
-
{
|
| 286 |
</td>
|
| 287 |
<td className="px-6 py-4 flex items-center space-x-3">
|
| 288 |
<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>
|
|
@@ -303,16 +341,16 @@ export const StudentList: React.FC = () => {
|
|
| 303 |
</div>
|
| 304 |
) : '-'}
|
| 305 |
</td>
|
| 306 |
-
<td className="px-6 py-4 text-right">
|
| 307 |
-
{
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
) : <span className="text-gray-300 text-xs">只读</span>}
|
| 313 |
</td>
|
| 314 |
</tr>
|
| 315 |
-
)) : (
|
| 316 |
<tr>
|
| 317 |
<td colSpan={6} className="text-center py-10 text-gray-400">没有找到匹配的学生</td>
|
| 318 |
</tr>
|
|
@@ -328,12 +366,12 @@ export const StudentList: React.FC = () => {
|
|
| 328 |
</div>
|
| 329 |
)}
|
| 330 |
|
| 331 |
-
{/* Modal
|
| 332 |
{isModalOpen && (
|
| 333 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 334 |
<div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
| 335 |
-
<h3 className="font-bold text-lg mb-4"
|
| 336 |
-
<form onSubmit={
|
| 337 |
<div className="grid grid-cols-2 gap-4">
|
| 338 |
<input className="w-full border p-2 rounded" placeholder="姓名 *" value={formData.name} onChange={e=>setFormData({...formData, name:e.target.value})} required/>
|
| 339 |
<input className="w-full border p-2 rounded" placeholder="学号 *" value={formData.studentNo} onChange={e=>setFormData({...formData, studentNo:e.target.value})} required/>
|
|
@@ -364,8 +402,6 @@ export const StudentList: React.FC = () => {
|
|
| 364 |
</div>
|
| 365 |
<button type="submit" disabled={submitting} className="w-full bg-blue-600 text-white py-2 rounded mt-4">{submitting?'提交中':'保存'}</button>
|
| 366 |
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded mt-2">取消</button>
|
| 367 |
-
|
| 368 |
-
|
| 369 |
</form>
|
| 370 |
</div>
|
| 371 |
</div>
|
|
@@ -411,4 +447,4 @@ export const StudentList: React.FC = () => {
|
|
| 411 |
)}
|
| 412 |
</div>
|
| 413 |
);
|
| 414 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { Search, Plus, Upload, Edit, Trash2, X, Loader2, User, FileSpreadsheet } from 'lucide-react';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
import { Student, ClassInfo } from '../types';
|
| 6 |
+
import { sortGrades, sortClasses } from './Dashboard'; // Reuse Sort Logic if possible, otherwise define locally
|
| 7 |
+
|
| 8 |
+
// Re-define sort logic locally to be safe if imports fail or circular dependency risks
|
| 9 |
+
const localSortGrades = (a: string, b: string) => {
|
| 10 |
+
const order: Record<string, number> = { '一年级': 1, '二年级': 2, '三年级': 3, '四年级': 4, '五年级': 5, '六年级': 6, '初一': 7, '七年级': 7, '初二': 8, '八年级': 8, '初三': 9, '九年级': 9, '高一': 10, '高二': 11, '高三': 12 };
|
| 11 |
+
return (order[a] || 99) - (order[b] || 99);
|
| 12 |
+
};
|
| 13 |
|
| 14 |
export const StudentList: React.FC = () => {
|
|
|
|
| 15 |
const [searchTerm, setSearchTerm] = useState('');
|
| 16 |
const [students, setStudents] = useState<Student[]>([]);
|
| 17 |
const [classList, setClassList] = useState<ClassInfo[]>([]);
|
|
|
|
| 22 |
const [submitting, setSubmitting] = useState(false);
|
| 23 |
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 24 |
|
| 25 |
+
// Edit Mode State
|
| 26 |
+
const [editStudentId, setEditStudentId] = useState<string | null>(null);
|
| 27 |
+
|
| 28 |
// Filters
|
| 29 |
const [selectedGrade, setSelectedGrade] = useState('All');
|
| 30 |
const [selectedClass, setSelectedClass] = useState('All');
|
|
|
|
| 34 |
const [importTargetClass, setImportTargetClass] = useState('');
|
| 35 |
|
| 36 |
// Form State
|
| 37 |
+
const initialForm = {
|
| 38 |
name: '',
|
| 39 |
studentNo: '',
|
| 40 |
gender: 'Male',
|
|
|
|
| 44 |
parentName: '',
|
| 45 |
parentPhone: '',
|
| 46 |
address: ''
|
| 47 |
+
};
|
| 48 |
+
const [formData, setFormData] = useState(initialForm);
|
| 49 |
|
| 50 |
const currentUser = api.auth.getCurrentUser();
|
| 51 |
|
|
|
|
| 67 |
|
| 68 |
useEffect(() => { loadData(); }, []);
|
| 69 |
|
|
|
|
|
|
|
|
|
|
| 70 |
const hasPermission = (className: string) => {
|
| 71 |
if (!currentUser) return false;
|
| 72 |
if (currentUser.role === 'ADMIN') return true;
|
|
|
|
| 73 |
if (currentUser.role === 'TEACHER') {
|
| 74 |
return currentUser.homeroomClass === className;
|
| 75 |
}
|
| 76 |
return false;
|
| 77 |
};
|
|
|
|
| 78 |
|
| 79 |
const handleDelete = async (id: string, className: string) => {
|
| 80 |
if (!hasPermission(className)) return alert('您没有权限操作此班级学生');
|
|
|
|
| 108 |
}
|
| 109 |
};
|
| 110 |
|
| 111 |
+
const handleOpenAdd = () => {
|
| 112 |
+
setEditStudentId(null);
|
| 113 |
+
setFormData(initialForm);
|
| 114 |
+
setIsModalOpen(true);
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const handleOpenEdit = (student: Student) => {
|
| 118 |
+
if (!hasPermission(student.className)) return alert('无权编辑此学生');
|
| 119 |
+
setEditStudentId(student._id || String(student.id));
|
| 120 |
+
setFormData({
|
| 121 |
+
name: student.name,
|
| 122 |
+
studentNo: student.studentNo,
|
| 123 |
+
gender: (student.gender === 'Female' ? 'Female' : 'Male'), // Ensure type match
|
| 124 |
+
className: student.className,
|
| 125 |
+
phone: student.phone || '',
|
| 126 |
+
idCard: student.idCard || '',
|
| 127 |
+
parentName: student.parentName || '',
|
| 128 |
+
parentPhone: student.parentPhone || '',
|
| 129 |
+
address: student.address || ''
|
| 130 |
+
});
|
| 131 |
+
setIsModalOpen(true);
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 135 |
e.preventDefault();
|
| 136 |
setSubmitting(true);
|
| 137 |
try {
|
| 138 |
+
const payload = {
|
| 139 |
...formData,
|
| 140 |
+
birthday: '2015-01-01', // Default for now, could add to form
|
| 141 |
status: 'Enrolled',
|
| 142 |
gender: formData.gender as any
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
if (editStudentId) {
|
| 146 |
+
await api.students.update(editStudentId, payload);
|
| 147 |
+
} else {
|
| 148 |
+
await api.students.add(payload);
|
| 149 |
+
}
|
| 150 |
setIsModalOpen(false);
|
| 151 |
loadData();
|
| 152 |
+
} catch (error) { alert(editStudentId ? '更新失败' : '添加失败'); }
|
| 153 |
finally { setSubmitting(false); }
|
| 154 |
};
|
| 155 |
|
|
|
|
| 216 |
|
| 217 |
const targetClassName = importTargetClass || getVal(['班级', 'Class'], 99) || '未分配';
|
| 218 |
|
|
|
|
| 219 |
if (!hasPermission(targetClassName)) {
|
| 220 |
console.warn('Skipping import for class due to permission:', targetClassName);
|
| 221 |
continue;
|
|
|
|
| 255 |
return matchesSearch && matchesGrade && matchesClass;
|
| 256 |
});
|
| 257 |
|
| 258 |
+
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(localSortGrades);
|
| 259 |
const filteredClasses = classList.filter(c => selectedGrade === 'All' || c.grade === selectedGrade);
|
| 260 |
+
const uniqueClasses = Array.from(new Set(filteredClasses.map(c => c.className))).sort(); // Class names usually 1班 2班 sort naturally
|
| 261 |
|
| 262 |
const canGlobalAdd = currentUser?.role === 'ADMIN' || (currentUser?.role === 'TEACHER' && currentUser.homeroomClass);
|
| 263 |
|
|
|
|
| 276 |
<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">
|
| 277 |
<FileSpreadsheet size={16}/><span>Excel导入</span>
|
| 278 |
</button>
|
| 279 |
+
<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">
|
| 280 |
<Plus size={16}/><span>新增</span>
|
| 281 |
</button>
|
| 282 |
</div>
|
|
|
|
| 310 |
<th className="px-6 py-3">基本信息</th>
|
| 311 |
<th className="px-6 py-3">学号</th>
|
| 312 |
<th className="px-6 py-3">班级</th>
|
| 313 |
+
<th className="px-6 py-3">家长/住址</th>
|
| 314 |
+
<th className="px-6 py-3 text-right">操作</th>
|
| 315 |
</tr>
|
| 316 |
</thead>
|
| 317 |
<tbody className="divide-y divide-gray-100">
|
| 318 |
+
{filteredStudents.length > 0 ? filteredStudents.map(s => {
|
| 319 |
+
const canEdit = hasPermission(s.className);
|
| 320 |
+
return (
|
| 321 |
<tr key={s._id || s.id} className="hover:bg-blue-50/30 transition-colors">
|
| 322 |
<td className="px-6 py-4">
|
| 323 |
+
{canEdit && <input type="checkbox" checked={selectedIds.has(s._id || String(s.id))} onChange={() => toggleSelect(s._id || String(s.id))} />}
|
| 324 |
</td>
|
| 325 |
<td className="px-6 py-4 flex items-center space-x-3">
|
| 326 |
<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>
|
|
|
|
| 341 |
</div>
|
| 342 |
) : '-'}
|
| 343 |
</td>
|
| 344 |
+
<td className="px-6 py-4 text-right flex justify-end space-x-2">
|
| 345 |
+
{canEdit ? (
|
| 346 |
+
<>
|
| 347 |
+
<button onClick={() => handleOpenEdit(s)} className="text-blue-400 hover:text-blue-600" title="编辑"><Edit size={16}/></button>
|
| 348 |
+
<button onClick={() => handleDelete(s._id || String(s.id), s.className)} className="text-red-400 hover:text-red-600" title="删除"><Trash2 size={16}/></button>
|
| 349 |
+
</>
|
| 350 |
) : <span className="text-gray-300 text-xs">只读</span>}
|
| 351 |
</td>
|
| 352 |
</tr>
|
| 353 |
+
)}) : (
|
| 354 |
<tr>
|
| 355 |
<td colSpan={6} className="text-center py-10 text-gray-400">没有找到匹配的学生</td>
|
| 356 |
</tr>
|
|
|
|
| 366 |
</div>
|
| 367 |
)}
|
| 368 |
|
| 369 |
+
{/* Modal */}
|
| 370 |
{isModalOpen && (
|
| 371 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 372 |
<div className="bg-white rounded-xl p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
| 373 |
+
<h3 className="font-bold text-lg mb-4">{editStudentId ? '编辑学生档案' : '新增学生档案'}</h3>
|
| 374 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 375 |
<div className="grid grid-cols-2 gap-4">
|
| 376 |
<input className="w-full border p-2 rounded" placeholder="姓名 *" value={formData.name} onChange={e=>setFormData({...formData, name:e.target.value})} required/>
|
| 377 |
<input className="w-full border p-2 rounded" placeholder="学号 *" value={formData.studentNo} onChange={e=>setFormData({...formData, studentNo:e.target.value})} required/>
|
|
|
|
| 402 |
</div>
|
| 403 |
<button type="submit" disabled={submitting} className="w-full bg-blue-600 text-white py-2 rounded mt-4">{submitting?'提交中':'保存'}</button>
|
| 404 |
<button type="button" onClick={()=>setIsModalOpen(false)} className="w-full border py-2 rounded mt-2">取消</button>
|
|
|
|
|
|
|
| 405 |
</form>
|
| 406 |
</div>
|
| 407 |
</div>
|
|
|
|
| 447 |
)}
|
| 448 |
</div>
|
| 449 |
);
|
| 450 |
+
};
|
pages/SubjectList.tsx
CHANGED
|
@@ -2,11 +2,13 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { Subject } from '../types';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
-
import { Loader2, Plus, Trash2, Palette, Edit, Save, X } from 'lucide-react';
|
| 6 |
|
| 7 |
export const SubjectList: React.FC = () => {
|
| 8 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 9 |
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
| 10 |
|
| 11 |
// State for adding new subject
|
| 12 |
const [newSub, setNewSub] = useState({ name: '', code: '', color: '#3b82f6', excellenceThreshold: 90 });
|
|
@@ -55,6 +57,14 @@ export const SubjectList: React.FC = () => {
|
|
| 55 |
}
|
| 56 |
};
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
if(loading) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 59 |
|
| 60 |
return (
|
|
@@ -65,7 +75,15 @@ export const SubjectList: React.FC = () => {
|
|
| 65 |
学科设置
|
| 66 |
</h2>
|
| 67 |
|
| 68 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
<div className="flex flex-col md:flex-row gap-4 mb-8 bg-gray-50 p-4 rounded-lg md:items-end">
|
| 70 |
<div className="flex-1">
|
| 71 |
<label className="text-xs font-bold text-gray-500 uppercase">学科名称</label>
|
|
@@ -95,38 +113,75 @@ export const SubjectList: React.FC = () => {
|
|
| 95 |
<Plus size={16} className="mr-1" /> 添加
|
| 96 |
</button>
|
| 97 |
</div>
|
|
|
|
| 98 |
|
| 99 |
{/* List */}
|
| 100 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 101 |
{subjects.map(sub => {
|
| 102 |
const isEditing = editingId === (sub._id || String(sub.id));
|
|
|
|
|
|
|
| 103 |
return (
|
| 104 |
<div key={sub._id || sub.id} className="border border-gray-200 rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition-shadow">
|
| 105 |
{isEditing ? (
|
| 106 |
<div className="space-y-3">
|
| 107 |
-
|
| 108 |
-
<div className="
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
|
|
|
| 112 |
<div className="flex gap-2">
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</div>
|
| 117 |
</div>
|
| 118 |
) : (
|
| 119 |
-
<div className="flex justify-between items-center">
|
| 120 |
<div className="flex items-center space-x-3">
|
| 121 |
-
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: sub.color }}></div>
|
| 122 |
<div>
|
| 123 |
<p className="font-bold text-gray-800">{sub.name}</p>
|
| 124 |
-
<p className="text-xs text-gray-500">{sub.code || '-'} | 优秀线: {sub.excellenceThreshold || 90}</p>
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
<div className="flex space-x-1">
|
| 128 |
-
<button onClick={() => handleEdit(sub)} className="text-gray-400 hover:text-blue-500 p-1"><Edit size={16} /></button>
|
| 129 |
-
<button onClick={() => handleDelete(sub._id || String(sub.id))} className="text-gray-400 hover:text-red-500 p-1"><Trash2 size={16} /></button>
|
|
|
|
| 130 |
</div>
|
| 131 |
</div>
|
| 132 |
)}
|
|
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { Subject } from '../types';
|
| 4 |
import { api } from '../services/api';
|
| 5 |
+
import { Loader2, Plus, Trash2, Palette, Edit, Save, X, Lock } from 'lucide-react';
|
| 6 |
|
| 7 |
export const SubjectList: React.FC = () => {
|
| 8 |
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 9 |
const [loading, setLoading] = useState(true);
|
| 10 |
+
const currentUser = api.auth.getCurrentUser();
|
| 11 |
+
const isAdmin = currentUser?.role === 'ADMIN';
|
| 12 |
|
| 13 |
// State for adding new subject
|
| 14 |
const [newSub, setNewSub] = useState({ name: '', code: '', color: '#3b82f6', excellenceThreshold: 90 });
|
|
|
|
| 57 |
}
|
| 58 |
};
|
| 59 |
|
| 60 |
+
// Permission Check for Teachers
|
| 61 |
+
const canEditSubject = (sub: Subject) => {
|
| 62 |
+
if (isAdmin) return true;
|
| 63 |
+
// Teacher can only edit if it matches their teaching subject
|
| 64 |
+
if (currentUser?.role === 'TEACHER' && currentUser.teachingSubject === sub.name) return true;
|
| 65 |
+
return false;
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
if(loading) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 69 |
|
| 70 |
return (
|
|
|
|
| 75 |
学科设置
|
| 76 |
</h2>
|
| 77 |
|
| 78 |
+
{!isAdmin && (
|
| 79 |
+
<div className="bg-blue-50 text-blue-700 px-4 py-2 rounded-lg text-sm mb-4 border border-blue-100 flex items-center">
|
| 80 |
+
<Lock size={14} className="mr-2"/>
|
| 81 |
+
教师权限提示:您仅可调整自己任教科目 ({currentUser?.teachingSubject || '无'}) 的“优秀线”标准,其他信息只读。
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
|
| 85 |
+
{/* Add Form (Only Admin) */}
|
| 86 |
+
{isAdmin && (
|
| 87 |
<div className="flex flex-col md:flex-row gap-4 mb-8 bg-gray-50 p-4 rounded-lg md:items-end">
|
| 88 |
<div className="flex-1">
|
| 89 |
<label className="text-xs font-bold text-gray-500 uppercase">学科名称</label>
|
|
|
|
| 113 |
<Plus size={16} className="mr-1" /> 添加
|
| 114 |
</button>
|
| 115 |
</div>
|
| 116 |
+
)}
|
| 117 |
|
| 118 |
{/* List */}
|
| 119 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 120 |
{subjects.map(sub => {
|
| 121 |
const isEditing = editingId === (sub._id || String(sub.id));
|
| 122 |
+
const canEdit = canEditSubject(sub);
|
| 123 |
+
|
| 124 |
return (
|
| 125 |
<div key={sub._id || sub.id} className="border border-gray-200 rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition-shadow">
|
| 126 |
{isEditing ? (
|
| 127 |
<div className="space-y-3">
|
| 128 |
+
{/* Admin gets full edit, Teacher gets limited edit */}
|
| 129 |
+
<div className="space-y-1">
|
| 130 |
+
<label className="text-xs text-gray-400">学科名称</label>
|
| 131 |
+
<input
|
| 132 |
+
className={`w-full border p-1 rounded text-sm ${!isAdmin ? 'bg-gray-100 text-gray-500' : ''}`}
|
| 133 |
+
value={editForm.name}
|
| 134 |
+
onChange={e => setEditForm({...editForm, name: e.target.value})}
|
| 135 |
+
disabled={!isAdmin}
|
| 136 |
+
/>
|
| 137 |
</div>
|
| 138 |
+
|
| 139 |
<div className="flex gap-2">
|
| 140 |
+
<div className="w-1/2 space-y-1">
|
| 141 |
+
<label className="text-xs text-gray-400">代码</label>
|
| 142 |
+
<input
|
| 143 |
+
className={`w-full border p-1 rounded text-sm ${!isAdmin ? 'bg-gray-100 text-gray-500' : ''}`}
|
| 144 |
+
placeholder="代码"
|
| 145 |
+
value={editForm.code}
|
| 146 |
+
onChange={e => setEditForm({...editForm, code: e.target.value})}
|
| 147 |
+
disabled={!isAdmin}
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
<div className="w-1/2 space-y-1">
|
| 151 |
+
<label className="text-xs text-blue-600 font-bold">优秀线</label>
|
| 152 |
+
<input
|
| 153 |
+
className="w-full border p-1 rounded text-sm border-blue-300 focus:ring-1 focus:ring-blue-500"
|
| 154 |
+
type="number"
|
| 155 |
+
placeholder="优秀线"
|
| 156 |
+
value={editForm.excellenceThreshold}
|
| 157 |
+
onChange={e => setEditForm({...editForm, excellenceThreshold: Number(e.target.value)})}
|
| 158 |
+
autoFocus={!isAdmin}
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<div className="flex gap-2 items-end">
|
| 164 |
+
<div className="flex-1">
|
| 165 |
+
<label className="text-xs text-gray-400 block mb-1">颜色</label>
|
| 166 |
+
<input type="color" className="h-8 w-full cursor-pointer" value={editForm.color} onChange={e => setEditForm({...editForm, color: e.target.value})} disabled={!isAdmin} />
|
| 167 |
+
</div>
|
| 168 |
+
<button onClick={handleUpdate} className="text-green-600 p-1.5 border rounded hover:bg-green-50"><Save size={16}/></button>
|
| 169 |
+
<button onClick={() => setEditingId(null)} className="text-gray-500 p-1.5 border rounded hover:bg-gray-50"><X size={16}/></button>
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
) : (
|
| 173 |
+
<div className="flex justify-between items-center h-full">
|
| 174 |
<div className="flex items-center space-x-3">
|
| 175 |
+
<div className="w-4 h-4 rounded-full shadow-sm" style={{ backgroundColor: sub.color }}></div>
|
| 176 |
<div>
|
| 177 |
<p className="font-bold text-gray-800">{sub.name}</p>
|
| 178 |
+
<p className="text-xs text-gray-500">{sub.code || '-'} | 优秀线: <span className="font-bold text-blue-600">{sub.excellenceThreshold || 90}</span></p>
|
| 179 |
</div>
|
| 180 |
</div>
|
| 181 |
<div className="flex space-x-1">
|
| 182 |
+
{canEdit && <button onClick={() => handleEdit(sub)} className="text-gray-400 hover:text-blue-500 p-1" title="编辑"><Edit size={16} /></button>}
|
| 183 |
+
{isAdmin && <button onClick={() => handleDelete(sub._id || String(sub.id))} className="text-gray-400 hover:text-red-500 p-1" title="删除"><Trash2 size={16} /></button>}
|
| 184 |
+
{!canEdit && !isAdmin && <span className="text-xs text-gray-300">只读</span>}
|
| 185 |
</div>
|
| 186 |
</div>
|
| 187 |
)}
|