Spaces:
Running
Running
Upload 51 files
Browse files- components/ConfirmModal.tsx +53 -0
- components/Sidebar.tsx +83 -26
- components/TodoList.tsx +94 -0
- models.js +30 -17
- pages/AchievementTeacher.tsx +85 -283
- pages/Attendance.tsx +72 -118
- pages/Dashboard.tsx +100 -380
- pages/GameMountain.tsx +33 -107
- pages/GameRewards.tsx +49 -76
- pages/TeacherDashboard.tsx +283 -0
- pages/WishesAndFeedback.tsx +70 -366
- server.js +68 -62
- services/api.ts +13 -8
- types.ts +165 -219
components/ConfirmModal.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import { X, AlertTriangle } from 'lucide-react';
|
| 4 |
+
|
| 5 |
+
interface ConfirmModalProps {
|
| 6 |
+
isOpen: boolean;
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
onConfirm: () => void;
|
| 9 |
+
title?: string;
|
| 10 |
+
message: string;
|
| 11 |
+
confirmText?: string;
|
| 12 |
+
cancelText?: string;
|
| 13 |
+
isDanger?: boolean;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
| 17 |
+
isOpen, onClose, onConfirm, title = "确认操作", message,
|
| 18 |
+
confirmText = "确定", cancelText = "取消", isDanger = false
|
| 19 |
+
}) => {
|
| 20 |
+
if (!isOpen) return null;
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="fixed inset-0 bg-black/60 z-[9999] flex items-center justify-center p-4 backdrop-blur-sm animate-in fade-in">
|
| 24 |
+
<div className="bg-white rounded-xl shadow-2xl max-w-sm w-full overflow-hidden animate-in zoom-in-95">
|
| 25 |
+
<div className="p-5">
|
| 26 |
+
<div className="flex items-start gap-4">
|
| 27 |
+
<div className={`p-3 rounded-full shrink-0 ${isDanger ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'}`}>
|
| 28 |
+
<AlertTriangle size={24} />
|
| 29 |
+
</div>
|
| 30 |
+
<div>
|
| 31 |
+
<h3 className="text-lg font-bold text-gray-900 mb-1">{title}</h3>
|
| 32 |
+
<p className="text-sm text-gray-500 leading-relaxed">{message}</p>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
</div>
|
| 36 |
+
<div className="bg-gray-50 px-5 py-3 flex justify-end gap-3">
|
| 37 |
+
<button
|
| 38 |
+
onClick={onClose}
|
| 39 |
+
className="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-200 transition-colors"
|
| 40 |
+
>
|
| 41 |
+
{cancelText}
|
| 42 |
+
</button>
|
| 43 |
+
<button
|
| 44 |
+
onClick={() => { onConfirm(); onClose(); }}
|
| 45 |
+
className={`px-4 py-2 rounded-lg text-sm font-bold text-white shadow-sm transition-colors ${isDanger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}`}
|
| 46 |
+
>
|
| 47 |
+
{confirmText}
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
);
|
| 53 |
+
};
|
components/Sidebar.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
|
| 2 |
-
import React from 'react';
|
| 3 |
-
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle, MessageSquare, Bot } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
| 5 |
import { api } from '../services/api';
|
| 6 |
|
|
@@ -14,24 +14,19 @@ interface SidebarProps {
|
|
| 14 |
}
|
| 15 |
|
| 16 |
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
|
| 17 |
-
// Get full user object to check aiAccess flag
|
| 18 |
const currentUser = api.auth.getCurrentUser();
|
| 19 |
-
|
| 20 |
-
// Logic: Admin sees it to manage it. Teachers see it only if granted. Students/Principal see nothing.
|
| 21 |
const canSeeAI = userRole === UserRole.ADMIN || (userRole === UserRole.TEACHER && currentUser?.aiAccess);
|
| 22 |
|
| 23 |
-
//
|
| 24 |
-
|
| 25 |
-
const menuItems = [
|
| 26 |
{ id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
| 27 |
-
// AI Assistant: Only if hasAIAccess is true
|
| 28 |
{ id: 'ai-assistant', label: 'AI 智能助教', icon: Bot, roles: canSeeAI ? [UserRole.ADMIN, UserRole.TEACHER] : [] },
|
| 29 |
{ id: 'attendance', label: '考勤管理', icon: CalendarCheck, roles: [UserRole.TEACHER, UserRole.PRINCIPAL] },
|
| 30 |
-
{ id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] },
|
| 31 |
-
{ id: 'wishes', label: '心愿与反馈', icon: MessageSquare, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
| 32 |
{ id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 33 |
{ id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
|
| 34 |
-
{ id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] },
|
| 35 |
{ id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 36 |
{ id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 37 |
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
|
@@ -41,6 +36,48 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
|
|
| 41 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
|
| 42 |
];
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
const sidebarClasses = `
|
| 45 |
fixed inset-y-0 left-0 z-50 w-64 bg-slate-900 text-white transition-transform duration-300 ease-in-out transform
|
| 46 |
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
@@ -62,25 +99,45 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
|
|
| 62 |
<button onClick={onClose} className="lg:hidden text-slate-400 hover:text-white"><X size={24} /></button>
|
| 63 |
</div>
|
| 64 |
|
| 65 |
-
<div className="flex-1 overflow-y-auto py-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
<nav className="space-y-1 px-2">
|
| 67 |
-
{menuItems.map((item) => {
|
| 68 |
if (!item.roles.includes(userRole)) return null;
|
|
|
|
|
|
|
| 69 |
const Icon = item.icon;
|
| 70 |
const isActive = currentView === item.id;
|
|
|
|
| 71 |
return (
|
| 72 |
-
<
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
);
|
| 85 |
})}
|
| 86 |
</nav>
|
|
|
|
| 1 |
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle, MessageSquare, Bot, ArrowUp, ArrowDown, Save } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
| 5 |
import { api } from '../services/api';
|
| 6 |
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
|
|
|
|
| 17 |
const currentUser = api.auth.getCurrentUser();
|
|
|
|
|
|
|
| 18 |
const canSeeAI = userRole === UserRole.ADMIN || (userRole === UserRole.TEACHER && currentUser?.aiAccess);
|
| 19 |
|
| 20 |
+
// Default Items
|
| 21 |
+
const defaultItems = [
|
|
|
|
| 22 |
{ id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
|
|
|
| 23 |
{ id: 'ai-assistant', label: 'AI 智能助教', icon: Bot, roles: canSeeAI ? [UserRole.ADMIN, UserRole.TEACHER] : [] },
|
| 24 |
{ id: 'attendance', label: '考勤管理', icon: CalendarCheck, roles: [UserRole.TEACHER, UserRole.PRINCIPAL] },
|
| 25 |
+
{ id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] },
|
| 26 |
+
{ id: 'wishes', label: '心愿与反馈', icon: MessageSquare, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
| 27 |
{ id: 'students', label: '学生管理', icon: Users, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 28 |
{ id: 'classes', label: '班级管理', icon: School, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
|
| 29 |
+
{ id: 'schools', label: '学校管理', icon: Building, roles: [UserRole.ADMIN] },
|
| 30 |
{ id: 'courses', label: '课程管理', icon: BookOpen, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 31 |
{ id: 'grades', label: '成绩管理', icon: GraduationCap, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 32 |
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
|
|
|
| 36 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
|
| 37 |
];
|
| 38 |
|
| 39 |
+
const [menuItems, setMenuItems] = useState(defaultItems);
|
| 40 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
// Load saved order
|
| 44 |
+
if (currentUser?.menuOrder && currentUser.menuOrder.length > 0) {
|
| 45 |
+
const ordered = [];
|
| 46 |
+
const map = new Map(defaultItems.map(i => [i.id, i]));
|
| 47 |
+
// Add saved items in order
|
| 48 |
+
currentUser.menuOrder.forEach(id => {
|
| 49 |
+
if (map.has(id)) {
|
| 50 |
+
ordered.push(map.get(id)!);
|
| 51 |
+
map.delete(id);
|
| 52 |
+
}
|
| 53 |
+
});
|
| 54 |
+
// Append any new/remaining items
|
| 55 |
+
map.forEach(item => ordered.push(item));
|
| 56 |
+
setMenuItems(ordered);
|
| 57 |
+
}
|
| 58 |
+
}, []);
|
| 59 |
+
|
| 60 |
+
const handleMove = (index: number, direction: -1 | 1) => {
|
| 61 |
+
const newItems = [...menuItems];
|
| 62 |
+
const targetIndex = index + direction;
|
| 63 |
+
if (targetIndex < 0 || targetIndex >= newItems.length) return;
|
| 64 |
+
[newItems[index], newItems[targetIndex]] = [newItems[targetIndex], newItems[index]];
|
| 65 |
+
setMenuItems(newItems);
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
const saveOrder = async () => {
|
| 69 |
+
setIsEditing(false);
|
| 70 |
+
const orderIds = menuItems.map(i => i.id);
|
| 71 |
+
if (currentUser && currentUser._id) {
|
| 72 |
+
try {
|
| 73 |
+
await api.users.saveMenuOrder(currentUser._id, orderIds);
|
| 74 |
+
// Update local user object
|
| 75 |
+
const updatedUser = { ...currentUser, menuOrder: orderIds };
|
| 76 |
+
localStorage.setItem('user', JSON.stringify(updatedUser));
|
| 77 |
+
} catch(e) { console.error("Failed to save menu order"); }
|
| 78 |
+
}
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
const sidebarClasses = `
|
| 82 |
fixed inset-y-0 left-0 z-50 w-64 bg-slate-900 text-white transition-transform duration-300 ease-in-out transform
|
| 83 |
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
|
|
|
| 99 |
<button onClick={onClose} className="lg:hidden text-slate-400 hover:text-white"><X size={24} /></button>
|
| 100 |
</div>
|
| 101 |
|
| 102 |
+
<div className="flex-1 overflow-y-auto py-4 custom-scrollbar">
|
| 103 |
+
<div className="px-4 mb-2 flex justify-between items-center">
|
| 104 |
+
<span className="text-xs text-slate-500 font-bold uppercase">主菜单</span>
|
| 105 |
+
<button
|
| 106 |
+
onClick={() => isEditing ? saveOrder() : setIsEditing(true)}
|
| 107 |
+
className={`text-xs px-2 py-1 rounded hover:bg-slate-800 ${isEditing ? 'text-green-400' : 'text-slate-600'}`}
|
| 108 |
+
>
|
| 109 |
+
{isEditing ? '完成' : '调整'}
|
| 110 |
+
</button>
|
| 111 |
+
</div>
|
| 112 |
<nav className="space-y-1 px-2">
|
| 113 |
+
{menuItems.map((item, idx) => {
|
| 114 |
if (!item.roles.includes(userRole)) return null;
|
| 115 |
+
if (item.id === 'ai-assistant' && !canSeeAI) return null;
|
| 116 |
+
|
| 117 |
const Icon = item.icon;
|
| 118 |
const isActive = currentView === item.id;
|
| 119 |
+
|
| 120 |
return (
|
| 121 |
+
<div key={item.id} className="flex items-center gap-1 group">
|
| 122 |
+
<button
|
| 123 |
+
onClick={() => !isEditing && onChangeView(item.id)}
|
| 124 |
+
disabled={isEditing}
|
| 125 |
+
className={`flex-1 flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors duration-200 ${
|
| 126 |
+
isActive
|
| 127 |
+
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50'
|
| 128 |
+
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
| 129 |
+
} ${isEditing ? 'opacity-70 cursor-grab' : ''}`}
|
| 130 |
+
>
|
| 131 |
+
<Icon size={20} />
|
| 132 |
+
<span className="font-medium">{item.label}</span>
|
| 133 |
+
</button>
|
| 134 |
+
{isEditing && (
|
| 135 |
+
<div className="flex flex-col gap-1 pr-1">
|
| 136 |
+
<button onClick={()=>handleMove(idx, -1)} className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"><ArrowUp size={12}/></button>
|
| 137 |
+
<button onClick={()=>handleMove(idx, 1)} className="p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"><ArrowDown size={12}/></button>
|
| 138 |
+
</div>
|
| 139 |
+
)}
|
| 140 |
+
</div>
|
| 141 |
);
|
| 142 |
})}
|
| 143 |
</nav>
|
components/TodoList.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import { Todo } from '../types';
|
| 5 |
+
import { Plus, CheckCircle, Circle, Trash2, CalendarCheck } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export const TodoList: React.FC = () => {
|
| 8 |
+
const [todos, setTodos] = useState<Todo[]>([]);
|
| 9 |
+
const [inputValue, setInputValue] = useState('');
|
| 10 |
+
const [loading, setLoading] = useState(false);
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
loadTodos();
|
| 14 |
+
}, []);
|
| 15 |
+
|
| 16 |
+
const loadTodos = async () => {
|
| 17 |
+
try {
|
| 18 |
+
const list = await api.todos.getAll();
|
| 19 |
+
setTodos(list);
|
| 20 |
+
} catch (e) { console.error(e); }
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const handleAdd = async (e?: React.FormEvent) => {
|
| 24 |
+
e?.preventDefault();
|
| 25 |
+
if (!inputValue.trim()) return;
|
| 26 |
+
setLoading(true);
|
| 27 |
+
await api.todos.add(inputValue);
|
| 28 |
+
setInputValue('');
|
| 29 |
+
setLoading(false);
|
| 30 |
+
loadTodos();
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const handleToggle = async (t: Todo) => {
|
| 34 |
+
// Optimistic update
|
| 35 |
+
const newStatus = !t.isCompleted;
|
| 36 |
+
setTodos(todos.map(item => item._id === t._id ? { ...item, isCompleted: newStatus } : item));
|
| 37 |
+
await api.todos.update(t._id!, { isCompleted: newStatus });
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const handleDelete = async (id: string) => {
|
| 41 |
+
setTodos(todos.filter(t => t._id !== id));
|
| 42 |
+
await api.todos.delete(id);
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 flex flex-col h-full">
|
| 47 |
+
<div className="p-4 border-b border-gray-100 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-t-xl">
|
| 48 |
+
<h3 className="font-bold text-gray-800 flex items-center text-sm">
|
| 49 |
+
<CalendarCheck size={16} className="mr-2 text-indigo-600"/> 个人备忘录
|
| 50 |
+
</h3>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar min-h-[200px]">
|
| 54 |
+
{todos.length === 0 ? (
|
| 55 |
+
<div className="text-center text-gray-400 py-10 text-xs">
|
| 56 |
+
<p>暂无待办事项</p>
|
| 57 |
+
<p>记录今天的教学/学习任务吧!</p>
|
| 58 |
+
</div>
|
| 59 |
+
) : (
|
| 60 |
+
<ul className="space-y-1">
|
| 61 |
+
{todos.map(t => (
|
| 62 |
+
<li key={t._id} className="group flex items-start gap-2 p-2 hover:bg-gray-50 rounded transition-colors text-sm">
|
| 63 |
+
<button onClick={() => handleToggle(t)} className={`mt-0.5 shrink-0 ${t.isCompleted ? 'text-green-500' : 'text-gray-400 hover:text-blue-500'}`}>
|
| 64 |
+
{t.isCompleted ? <CheckCircle size={16}/> : <Circle size={16}/>}
|
| 65 |
+
</button>
|
| 66 |
+
<span className={`flex-1 break-all ${t.isCompleted ? 'text-gray-400 line-through' : 'text-gray-700'}`}>
|
| 67 |
+
{t.content}
|
| 68 |
+
</span>
|
| 69 |
+
<button onClick={() => handleDelete(t._id!)} className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 70 |
+
<Trash2 size={14}/>
|
| 71 |
+
</button>
|
| 72 |
+
</li>
|
| 73 |
+
))}
|
| 74 |
+
</ul>
|
| 75 |
+
)}
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div className="p-3 border-t border-gray-100 bg-gray-50 rounded-b-xl">
|
| 79 |
+
<form onSubmit={handleAdd} className="flex gap-2">
|
| 80 |
+
<input
|
| 81 |
+
className="flex-1 border border-gray-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-indigo-200 focus:border-indigo-400 bg-white"
|
| 82 |
+
placeholder="添加新任务..."
|
| 83 |
+
value={inputValue}
|
| 84 |
+
onChange={e => setInputValue(e.target.value)}
|
| 85 |
+
disabled={loading}
|
| 86 |
+
/>
|
| 87 |
+
<button type="submit" disabled={!inputValue.trim() || loading} className="bg-indigo-600 text-white p-1.5 rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
| 88 |
+
<Plus size={18}/>
|
| 89 |
+
</button>
|
| 90 |
+
</form>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
);
|
| 94 |
+
};
|
models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
|
| 2 |
const mongoose = require('mongoose');
|
| 3 |
|
| 4 |
-
// ... (Previous Models
|
| 5 |
|
| 6 |
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
| 7 |
const School = mongoose.model('School', SchoolSchema);
|
|
@@ -26,8 +26,8 @@ const UserSchema = new mongoose.Schema({
|
|
| 26 |
gender: String,
|
| 27 |
seatNo: String,
|
| 28 |
idCard: String,
|
| 29 |
-
// NEW: Control AI feature access for teachers
|
| 30 |
aiAccess: { type: Boolean, default: false },
|
|
|
|
| 31 |
classApplication: {
|
| 32 |
type: { type: String },
|
| 33 |
targetClass: String,
|
|
@@ -36,7 +36,7 @@ const UserSchema = new mongoose.Schema({
|
|
| 36 |
});
|
| 37 |
const User = mongoose.model('User', UserSchema);
|
| 38 |
|
| 39 |
-
// ... (
|
| 40 |
const StudentSchema = new mongoose.Schema({
|
| 41 |
schoolId: String,
|
| 42 |
studentNo: String,
|
|
@@ -90,7 +90,15 @@ const SubjectModel = mongoose.model('Subject', SubjectSchema);
|
|
| 90 |
const ExamSchema = new mongoose.Schema({ schoolId: String, name: String, date: String, type: String, semester: String });
|
| 91 |
const ExamModel = mongoose.model('Exam', ExamSchema);
|
| 92 |
|
| 93 |
-
const ScheduleSchema = new mongoose.Schema({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
|
| 95 |
|
| 96 |
const ConfigSchema = new mongoose.Schema({
|
|
@@ -104,16 +112,17 @@ const ConfigSchema = new mongoose.Schema({
|
|
| 104 |
allowStudentRegister: Boolean,
|
| 105 |
maintenanceMode: Boolean,
|
| 106 |
emailNotify: Boolean,
|
| 107 |
-
// NEW: AI Global Config
|
| 108 |
enableAI: { type: Boolean, default: true },
|
| 109 |
-
aiTotalCalls: { type: Number, default: 0 }
|
|
|
|
| 110 |
});
|
| 111 |
const ConfigModel = mongoose.model('Config', ConfigSchema);
|
| 112 |
|
|
|
|
| 113 |
const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
|
| 114 |
const NotificationModel = mongoose.model('Notification', NotificationSchema);
|
| 115 |
|
| 116 |
-
const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: Number, achievementId: String }] });
|
| 117 |
const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
|
| 118 |
|
| 119 |
const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, count: { type: Number, default: 1 }, status: String, source: String, createTime: { type: Date, default: Date.now }, ownerId: String });
|
|
@@ -155,7 +164,6 @@ const GameZenConfigSchema = new mongoose.Schema({
|
|
| 155 |
});
|
| 156 |
const GameZenConfigModel = mongoose.model('GameZenConfig', GameZenConfigSchema);
|
| 157 |
|
| 158 |
-
// Updated Achievement Schema with addedBy
|
| 159 |
const AchievementConfigSchema = new mongoose.Schema({
|
| 160 |
schoolId: String,
|
| 161 |
className: String,
|
|
@@ -168,12 +176,10 @@ const AchievementConfigSchema = new mongoose.Schema({
|
|
| 168 |
addedBy: String,
|
| 169 |
addedByName: String
|
| 170 |
}],
|
| 171 |
-
// Legacy support, rules now moving to TeacherExchangeConfig
|
| 172 |
exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }]
|
| 173 |
});
|
| 174 |
const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
|
| 175 |
|
| 176 |
-
// NEW: Independent Teacher Exchange Rules
|
| 177 |
const TeacherExchangeConfigSchema = new mongoose.Schema({
|
| 178 |
schoolId: String,
|
| 179 |
teacherId: String,
|
|
@@ -194,8 +200,6 @@ const LeaveRequestModel = mongoose.model('LeaveRequest', LeaveRequestSchema);
|
|
| 194 |
const SchoolCalendarSchema = new mongoose.Schema({ schoolId: String, className: String, type: String, startDate: String, endDate: String, name: String });
|
| 195 |
const SchoolCalendarModel = mongoose.model('SchoolCalendar', SchoolCalendarSchema);
|
| 196 |
|
| 197 |
-
// --- NEW SCHEMAS FOR WISH AND FEEDBACK ---
|
| 198 |
-
|
| 199 |
const WishSchema = new mongoose.Schema({
|
| 200 |
schoolId: String,
|
| 201 |
studentId: String,
|
|
@@ -204,7 +208,7 @@ const WishSchema = new mongoose.Schema({
|
|
| 204 |
teacherId: String,
|
| 205 |
teacherName: String,
|
| 206 |
content: String,
|
| 207 |
-
status: { type: String, default: 'PENDING' },
|
| 208 |
createTime: { type: Date, default: Date.now },
|
| 209 |
fulfillTime: Date
|
| 210 |
});
|
|
@@ -215,20 +219,29 @@ const FeedbackSchema = new mongoose.Schema({
|
|
| 215 |
creatorId: String,
|
| 216 |
creatorName: String,
|
| 217 |
creatorRole: String,
|
| 218 |
-
targetId: String,
|
| 219 |
targetName: String,
|
| 220 |
content: String,
|
| 221 |
-
type: String,
|
| 222 |
-
status: { type: String, default: 'PENDING' },
|
| 223 |
reply: String,
|
| 224 |
createTime: { type: Date, default: Date.now },
|
| 225 |
updateTime: Date
|
| 226 |
});
|
| 227 |
const FeedbackModel = mongoose.model('Feedback', FeedbackSchema);
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
module.exports = {
|
| 230 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 231 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 232 |
AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
|
| 233 |
-
WishModel, FeedbackModel
|
| 234 |
};
|
|
|
|
| 1 |
|
| 2 |
const mongoose = require('mongoose');
|
| 3 |
|
| 4 |
+
// ... (Previous Models)
|
| 5 |
|
| 6 |
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
| 7 |
const School = mongoose.model('School', SchoolSchema);
|
|
|
|
| 26 |
gender: String,
|
| 27 |
seatNo: String,
|
| 28 |
idCard: String,
|
|
|
|
| 29 |
aiAccess: { type: Boolean, default: false },
|
| 30 |
+
menuOrder: [String], // NEW
|
| 31 |
classApplication: {
|
| 32 |
type: { type: String },
|
| 33 |
targetClass: String,
|
|
|
|
| 36 |
});
|
| 37 |
const User = mongoose.model('User', UserSchema);
|
| 38 |
|
| 39 |
+
// ... (Student, Course, Score, Class, Subject, Exam Models - No Change)
|
| 40 |
const StudentSchema = new mongoose.Schema({
|
| 41 |
schoolId: String,
|
| 42 |
studentNo: String,
|
|
|
|
| 90 |
const ExamSchema = new mongoose.Schema({ schoolId: String, name: String, date: String, type: String, semester: String });
|
| 91 |
const ExamModel = mongoose.model('Exam', ExamSchema);
|
| 92 |
|
| 93 |
+
const ScheduleSchema = new mongoose.Schema({
|
| 94 |
+
schoolId: String,
|
| 95 |
+
className: String,
|
| 96 |
+
teacherName: String,
|
| 97 |
+
subject: String,
|
| 98 |
+
dayOfWeek: Number,
|
| 99 |
+
period: Number,
|
| 100 |
+
weekType: { type: String, default: 'ALL' } // ALL, ODD, EVEN
|
| 101 |
+
});
|
| 102 |
const ScheduleModel = mongoose.model('Schedule', ScheduleSchema);
|
| 103 |
|
| 104 |
const ConfigSchema = new mongoose.Schema({
|
|
|
|
| 112 |
allowStudentRegister: Boolean,
|
| 113 |
maintenanceMode: Boolean,
|
| 114 |
emailNotify: Boolean,
|
|
|
|
| 115 |
enableAI: { type: Boolean, default: true },
|
| 116 |
+
aiTotalCalls: { type: Number, default: 0 },
|
| 117 |
+
periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }] // NEW
|
| 118 |
});
|
| 119 |
const ConfigModel = mongoose.model('Config', ConfigSchema);
|
| 120 |
|
| 121 |
+
// ... (Notification, GameSession, StudentReward, LuckyDrawConfig, etc.)
|
| 122 |
const NotificationSchema = new mongoose.Schema({ schoolId: String, targetRole: String, targetUserId: String, title: String, content: String, type: String, createTime: { type: Date, default: Date.now } });
|
| 123 |
const NotificationModel = mongoose.model('Notification', NotificationSchema);
|
| 124 |
|
| 125 |
+
const GameSessionSchema = new mongoose.Schema({ schoolId: String, className: String, isEnabled: Boolean, maxSteps: Number, teams: [{ id: String, name: String, score: Number, avatar: String, color: String, members: [String] }], rewardsConfig: [{ scoreThreshold: Number, rewardType: String, rewardName: String, rewardValue: { type: Number, default: 1 }, achievementId: String }] }); // Added rewardValue default
|
| 126 |
const GameSessionModel = mongoose.model('GameSession', GameSessionSchema);
|
| 127 |
|
| 128 |
const StudentRewardSchema = new mongoose.Schema({ schoolId: String, studentId: String, studentName: String, rewardType: String, name: String, count: { type: Number, default: 1 }, status: String, source: String, createTime: { type: Date, default: Date.now }, ownerId: String });
|
|
|
|
| 164 |
});
|
| 165 |
const GameZenConfigModel = mongoose.model('GameZenConfig', GameZenConfigSchema);
|
| 166 |
|
|
|
|
| 167 |
const AchievementConfigSchema = new mongoose.Schema({
|
| 168 |
schoolId: String,
|
| 169 |
className: String,
|
|
|
|
| 176 |
addedBy: String,
|
| 177 |
addedByName: String
|
| 178 |
}],
|
|
|
|
| 179 |
exchangeRules: [{ id: String, cost: Number, rewardType: String, rewardName: String, rewardValue: Number }]
|
| 180 |
});
|
| 181 |
const AchievementConfigModel = mongoose.model('AchievementConfig', AchievementConfigSchema);
|
| 182 |
|
|
|
|
| 183 |
const TeacherExchangeConfigSchema = new mongoose.Schema({
|
| 184 |
schoolId: String,
|
| 185 |
teacherId: String,
|
|
|
|
| 200 |
const SchoolCalendarSchema = new mongoose.Schema({ schoolId: String, className: String, type: String, startDate: String, endDate: String, name: String });
|
| 201 |
const SchoolCalendarModel = mongoose.model('SchoolCalendar', SchoolCalendarSchema);
|
| 202 |
|
|
|
|
|
|
|
| 203 |
const WishSchema = new mongoose.Schema({
|
| 204 |
schoolId: String,
|
| 205 |
studentId: String,
|
|
|
|
| 208 |
teacherId: String,
|
| 209 |
teacherName: String,
|
| 210 |
content: String,
|
| 211 |
+
status: { type: String, default: 'PENDING' },
|
| 212 |
createTime: { type: Date, default: Date.now },
|
| 213 |
fulfillTime: Date
|
| 214 |
});
|
|
|
|
| 219 |
creatorId: String,
|
| 220 |
creatorName: String,
|
| 221 |
creatorRole: String,
|
| 222 |
+
targetId: String,
|
| 223 |
targetName: String,
|
| 224 |
content: String,
|
| 225 |
+
type: String,
|
| 226 |
+
status: { type: String, default: 'PENDING' },
|
| 227 |
reply: String,
|
| 228 |
createTime: { type: Date, default: Date.now },
|
| 229 |
updateTime: Date
|
| 230 |
});
|
| 231 |
const FeedbackModel = mongoose.model('Feedback', FeedbackSchema);
|
| 232 |
|
| 233 |
+
// NEW: Todo Model
|
| 234 |
+
const TodoSchema = new mongoose.Schema({
|
| 235 |
+
userId: String,
|
| 236 |
+
content: String,
|
| 237 |
+
isCompleted: { type: Boolean, default: false },
|
| 238 |
+
createTime: { type: Date, default: Date.now }
|
| 239 |
+
});
|
| 240 |
+
const TodoModel = mongoose.model('Todo', TodoSchema);
|
| 241 |
+
|
| 242 |
module.exports = {
|
| 243 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 244 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 245 |
AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
|
| 246 |
+
WishModel, FeedbackModel, TodoModel
|
| 247 |
};
|
pages/AchievementTeacher.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { api } from '../services/api';
|
|
| 4 |
import { AchievementConfig, AchievementItem, ExchangeRule, Student, TeacherExchangeConfig } from '../types';
|
| 5 |
import { Plus, Trash2, Edit, Save, Gift, Award, Coins, Users, Search, Loader2, CheckCircle, Filter } from 'lucide-react';
|
| 6 |
import { Emoji } from '../components/Emoji';
|
|
|
|
| 7 |
|
| 8 |
const PRESET_ICONS = [
|
| 9 |
{ icon: '🏆', label: '冠军杯' }, { icon: '🥇', label: '金牌' }, { icon: '🥈', label: '银牌' }, { icon: '🥉', label: '铜牌' },
|
|
@@ -19,141 +20,88 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
|
|
| 19 |
const [myExchangeConfig, setMyExchangeConfig] = useState<TeacherExchangeConfig | null>(null);
|
| 20 |
const [students, setStudents] = useState<Student[]>([]);
|
| 21 |
const [semester, setSemester] = useState('');
|
| 22 |
-
|
| 23 |
-
// Tab State
|
| 24 |
const [activeTab, setActiveTab] = useState<'manage' | 'grant' | 'exchange' | 'balance'>('manage');
|
| 25 |
-
|
| 26 |
-
// Forms
|
| 27 |
const [newAchieve, setNewAchieve] = useState<AchievementItem>({ id: '', name: '', icon: '🏆', points: 1 });
|
| 28 |
const [newRule, setNewRule] = useState<ExchangeRule>({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
const [filterCreator, setFilterCreator] = useState('ALL'); // For Manage
|
| 32 |
-
const [filterGrantCreator, setFilterGrantCreator] = useState('ALL'); // For Grant
|
| 33 |
-
|
| 34 |
-
// Grant State
|
| 35 |
const [selectedStudents, setSelectedStudents] = useState<Set<string>>(new Set());
|
| 36 |
const [selectedAchieveId, setSelectedAchieveId] = useState('');
|
|
|
|
| 37 |
|
| 38 |
const currentUser = api.auth.getCurrentUser();
|
| 39 |
const homeroomClass = className || currentUser?.homeroomClass;
|
| 40 |
-
|
| 41 |
-
// Permissions
|
| 42 |
-
const isHomeroom = currentUser?.homeroomClass === homeroomClass; // Is this the HT?
|
| 43 |
const isPrincipal = currentUser?.role === 'PRINCIPAL';
|
| 44 |
-
const canManageAll = isHomeroom || isPrincipal;
|
| 45 |
|
| 46 |
-
useEffect(() => {
|
| 47 |
-
loadData();
|
| 48 |
-
}, [homeroomClass]);
|
| 49 |
|
| 50 |
const loadData = async () => {
|
| 51 |
if (!homeroomClass) return;
|
| 52 |
setLoading(true);
|
| 53 |
-
|
| 54 |
try {
|
| 55 |
-
// 1. Students
|
| 56 |
const stus = await api.students.getAll();
|
| 57 |
-
const sortedStudents = stus
|
| 58 |
-
.filter((s: Student) => s.className.trim() === homeroomClass.trim())
|
| 59 |
-
.sort((a: Student, b: Student) => {
|
| 60 |
-
const seatA = parseInt(a.seatNo || '99999');
|
| 61 |
-
const seatB = parseInt(b.seatNo || '99999');
|
| 62 |
-
if (seatA !== seatB) return seatA - seatB;
|
| 63 |
-
return a.name.localeCompare(b.name, 'zh-CN');
|
| 64 |
-
});
|
| 65 |
setStudents(sortedStudents);
|
| 66 |
-
|
| 67 |
-
// 2. Shared Config (Achievements) & System
|
| 68 |
const [cfg, sysCfg, myRules] = await Promise.all([
|
| 69 |
api.achievements.getConfig(homeroomClass).catch(() => null),
|
| 70 |
api.config.getPublic().catch(() => ({ semester: '当前学期' })),
|
| 71 |
-
api.achievements.getMyRules().catch(() => ({ rules: [] }))
|
| 72 |
]);
|
| 73 |
-
|
| 74 |
-
const defaultConfig: AchievementConfig = {
|
| 75 |
-
schoolId: currentUser?.schoolId || '',
|
| 76 |
-
className: homeroomClass,
|
| 77 |
-
achievements: [],
|
| 78 |
-
exchangeRules: [] // Legacy, ignored for display now
|
| 79 |
-
};
|
| 80 |
-
|
| 81 |
setConfig(cfg || defaultConfig);
|
| 82 |
-
setMyExchangeConfig(myRules);
|
| 83 |
-
|
| 84 |
// @ts-ignore
|
| 85 |
setSemester(sysCfg?.semester || '当前学期');
|
| 86 |
-
|
| 87 |
-
} catch (e) {
|
| 88 |
-
console.error("Failed to load data", e);
|
| 89 |
-
} finally { setLoading(false); }
|
| 90 |
};
|
| 91 |
|
| 92 |
-
// --- Shared Achievement Config ---
|
| 93 |
const handleSaveConfig = async (newConfig: AchievementConfig) => {
|
| 94 |
setConfig(newConfig);
|
| 95 |
-
try {
|
| 96 |
-
await api.achievements.saveConfig(newConfig);
|
| 97 |
-
} catch (e) {
|
| 98 |
-
alert('保存失败'); loadData();
|
| 99 |
-
}
|
| 100 |
};
|
| 101 |
|
| 102 |
-
// --- Independent Teacher Rules ---
|
| 103 |
const handleSaveMyRules = async (newRules: ExchangeRule[]) => {
|
| 104 |
-
const payload = {
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
} as TeacherExchangeConfig;
|
| 108 |
-
|
| 109 |
-
setMyExchangeConfig(payload); // Optimistic
|
| 110 |
-
try {
|
| 111 |
-
await api.achievements.saveMyRules(payload);
|
| 112 |
-
} catch(e) { alert('保存失败'); loadData(); }
|
| 113 |
};
|
| 114 |
|
| 115 |
-
// --- Manage Achievements ---
|
| 116 |
const addAchievement = () => {
|
| 117 |
if (!config) return;
|
| 118 |
if (!newAchieve.name) return alert('请输入成就名称');
|
| 119 |
-
|
| 120 |
-
const newItem: AchievementItem = {
|
| 121 |
-
...newAchieve,
|
| 122 |
-
id: Date.now().toString(),
|
| 123 |
-
addedBy: currentUser?._id,
|
| 124 |
-
addedByName: currentUser?.trueName || currentUser?.username
|
| 125 |
-
};
|
| 126 |
const updated = { ...config, achievements: [...config.achievements, newItem] };
|
| 127 |
-
|
| 128 |
handleSaveConfig(updated);
|
| 129 |
setNewAchieve({ id: '', name: '', icon: '🏆', points: 1 });
|
| 130 |
};
|
| 131 |
|
| 132 |
const deleteAchievement = (item: AchievementItem) => {
|
| 133 |
if (!config) return;
|
| 134 |
-
// Permission Check: HT/Principal can delete all. Teacher can only delete own.
|
| 135 |
if (!canManageAll && item.addedBy !== currentUser?._id) return alert('权限不足:只能删除自己添加的成就');
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
};
|
| 141 |
|
| 142 |
-
// --- Grant ---
|
| 143 |
const handleGrant = async () => {
|
| 144 |
if (selectedStudents.size === 0 || !selectedAchieveId) return alert('请选择学生和奖状');
|
| 145 |
try {
|
| 146 |
-
const promises = Array.from(selectedStudents).map(sid =>
|
| 147 |
-
api.achievements.grant({ studentId: sid, achievementId: selectedAchieveId, semester })
|
| 148 |
-
);
|
| 149 |
await Promise.all(promises);
|
| 150 |
alert(`成功发放给 ${selectedStudents.size} 位学生`);
|
| 151 |
setSelectedStudents(new Set());
|
| 152 |
-
loadData();
|
| 153 |
} catch (e) { alert('部分发放失败'); }
|
| 154 |
};
|
| 155 |
|
| 156 |
-
// --- Independent Rules ---
|
| 157 |
const addRule = () => {
|
| 158 |
if (!newRule.rewardName) return alert('请输入奖励名称');
|
| 159 |
const newItem = { ...newRule, id: Date.now().toString() };
|
|
@@ -163,19 +111,30 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
|
|
| 163 |
};
|
| 164 |
|
| 165 |
const deleteRule = (id: string) => {
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
};
|
| 170 |
|
| 171 |
-
// Proxy Exchange (Using current teacher's rules)
|
| 172 |
const handleProxyExchange = async (studentId: string, ruleId: string) => {
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
};
|
| 180 |
|
| 181 |
if (!homeroomClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法使用成就管理功能。</div>;
|
|
@@ -183,64 +142,30 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
|
|
| 183 |
|
| 184 |
const safeConfig = config || { achievements: [], exchangeRules: [] };
|
| 185 |
const myRules = myExchangeConfig?.rules || [];
|
| 186 |
-
|
| 187 |
-
// Filter Logic
|
| 188 |
const uniqueCreators = Array.from(new Set(safeConfig.achievements.map(a => a.addedByName || '未知'))).filter(Boolean);
|
| 189 |
const filteredAchievements = safeConfig.achievements.filter(a => filterCreator === 'ALL' || a.addedByName === filterCreator);
|
| 190 |
const filteredGrantAchievements = safeConfig.achievements.filter(a => filterGrantCreator === 'ALL' || a.addedByName === filterGrantCreator);
|
| 191 |
|
| 192 |
return (
|
| 193 |
<div className="h-full flex flex-col bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
| 194 |
-
{
|
| 195 |
<div className="flex border-b border-gray-100 bg-gray-50/50 overflow-x-auto">
|
| 196 |
-
<button onClick={() => setActiveTab('manage')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'manage' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 197 |
-
|
| 198 |
-
</button>
|
| 199 |
-
<button onClick={() => setActiveTab('
|
| 200 |
-
<Gift size={18}/> 发放成就
|
| 201 |
-
</button>
|
| 202 |
-
<button onClick={() => setActiveTab('exchange')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'exchange' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 203 |
-
<Coins size={18}/> 兑换规则 (我的)
|
| 204 |
-
</button>
|
| 205 |
-
<button onClick={() => setActiveTab('balance')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'balance' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 206 |
-
<Users size={18}/> 学生积分
|
| 207 |
-
</button>
|
| 208 |
</div>
|
| 209 |
-
|
| 210 |
<div className="flex-1 overflow-y-auto p-6">
|
| 211 |
-
{/* 1. Manage Achievements */}
|
| 212 |
{activeTab === 'manage' && (
|
| 213 |
<div className="space-y-6 max-w-4xl mx-auto">
|
| 214 |
-
{/
|
| 215 |
-
<div className="flex justify-end items-center gap-2">
|
| 216 |
-
<span className="text-xs font-bold text-gray-500"><Filter size={14} className="inline mr-1"/>筛选添加者:</span>
|
| 217 |
-
<select className="border rounded text-xs p-1" value={filterCreator} onChange={e=>setFilterCreator(e.target.value)}>
|
| 218 |
-
<option value="ALL">全部老师</option>
|
| 219 |
-
{uniqueCreators.map(c => <option key={c} value={c}>{c}</option>)}
|
| 220 |
-
</select>
|
| 221 |
-
</div>
|
| 222 |
-
|
| 223 |
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100 flex flex-col md:flex-row gap-4 items-end">
|
| 224 |
-
<div className="flex-1 w-full">
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
</div>
|
| 228 |
-
<div className="w-full md:w-32">
|
| 229 |
-
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">分值 (小红花)</label>
|
| 230 |
-
<input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newAchieve.points} onChange={e => setNewAchieve({...newAchieve, points: Number(e.target.value)})} />
|
| 231 |
-
</div>
|
| 232 |
-
<div className="w-full md:w-auto">
|
| 233 |
-
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">图标预设</label>
|
| 234 |
-
<select className="w-full border rounded p-2 min-w-[100px] text-sm bg-white" value={newAchieve.icon} onChange={e => setNewAchieve({...newAchieve, icon: e.target.value})}>
|
| 235 |
-
{PRESET_ICONS.map(p => <option key={p.label} value={p.icon}>{p.icon} {p.label}</option>)}
|
| 236 |
-
</select>
|
| 237 |
-
</div>
|
| 238 |
<button onClick={addAchievement} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-blue-700 whitespace-nowrap text-sm">添加成就</button>
|
| 239 |
</div>
|
| 240 |
-
|
| 241 |
-
{filteredAchievements.length === 0 ? (
|
| 242 |
-
<div className="text-center text-gray-400 py-10">暂无成就,请添加</div>
|
| 243 |
-
) : (
|
| 244 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 245 |
{filteredAchievements.map(ach => {
|
| 246 |
const isMine = ach.addedBy === currentUser?._id;
|
|
@@ -249,49 +174,24 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
|
|
| 249 |
<div key={ach.id} className="border border-gray-200 rounded-xl p-4 flex flex-col items-center relative group hover:shadow-md transition-all bg-white">
|
| 250 |
<div className="text-4xl mb-2"><Emoji symbol={ach.icon} /></div>
|
| 251 |
<div className="font-bold text-gray-800 text-center">{ach.name}</div>
|
| 252 |
-
<div className="text-xs text-amber-600 font-bold bg-amber-50 px-2 py-0.5 rounded-full mt-1 border border-amber-100">
|
| 253 |
-
|
| 254 |
-
</
|
| 255 |
-
<div className="mt-2 text-[10px] text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded">
|
| 256 |
-
{ach.addedByName || '未知'}
|
| 257 |
-
</div>
|
| 258 |
-
{canDelete && (
|
| 259 |
-
<button onClick={() => deleteAchievement(ach)} className="absolute top-2 right-2 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 260 |
-
<Trash2 size={16}/>
|
| 261 |
-
</button>
|
| 262 |
-
)}
|
| 263 |
</div>
|
| 264 |
)})}
|
| 265 |
</div>
|
| 266 |
)}
|
| 267 |
</div>
|
| 268 |
)}
|
| 269 |
-
|
| 270 |
-
{/* 2. Grant Achievements */}
|
| 271 |
{activeTab === 'grant' && (
|
| 272 |
<div className="flex flex-col md:flex-row gap-6 h-full">
|
| 273 |
-
{/* Student Selector */}
|
| 274 |
<div className="w-full md:w-1/3 border-r border-gray-100 pr-4 flex flex-col min-h-[400px]">
|
| 275 |
<h3 className="font-bold text-gray-700 mb-2">1. 选择学生 ({selectedStudents.size})</h3>
|
| 276 |
-
<button onClick={() => setSelectedStudents(new Set(selectedStudents.size === students.length ? [] : students.map(s => s._id || String(s.id))))} className="text-xs text-blue-600 mb-2 text-left hover:underline">
|
| 277 |
-
|
| 278 |
-
</button>
|
| 279 |
-
{students.length === 0 ? (
|
| 280 |
-
<div className="text-sm text-gray-400">班级暂无学生数据</div>
|
| 281 |
-
) : (
|
| 282 |
<div className="flex-1 overflow-y-auto space-y-2 pr-2 custom-scrollbar max-h-[500px]">
|
| 283 |
{students.map(s => (
|
| 284 |
-
<div
|
| 285 |
-
key={s._id}
|
| 286 |
-
onClick={() => {
|
| 287 |
-
const newSet = new Set(selectedStudents);
|
| 288 |
-
const sid = s._id || String(s.id);
|
| 289 |
-
if (newSet.has(sid)) newSet.delete(sid);
|
| 290 |
-
else newSet.add(sid);
|
| 291 |
-
setSelectedStudents(newSet);
|
| 292 |
-
}}
|
| 293 |
-
className={`p-2 rounded border cursor-pointer flex items-center justify-between transition-colors ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
|
| 294 |
-
>
|
| 295 |
<span className="text-sm font-medium">{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
|
| 296 |
{selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
|
| 297 |
</div>
|
|
@@ -299,30 +199,12 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
|
|
| 299 |
</div>
|
| 300 |
)}
|
| 301 |
</div>
|
| 302 |
-
|
| 303 |
-
{/* Achievement Selector */}
|
| 304 |
<div className="flex-1 flex flex-col">
|
| 305 |
-
<div className="flex justify-between items-center mb-4">
|
| 306 |
-
|
| 307 |
-
<div className="flex items-center gap-2">
|
| 308 |
-
<span className="text-xs text-gray-500">来自:</span>
|
| 309 |
-
<select className="border rounded text-xs p-1" value={filterGrantCreator} onChange={e=>setFilterGrantCreator(e.target.value)}>
|
| 310 |
-
<option value="ALL">全部老师</option>
|
| 311 |
-
{uniqueCreators.map(c => <option key={c} value={c}>{c}</option>)}
|
| 312 |
-
</select>
|
| 313 |
-
</div>
|
| 314 |
-
</div>
|
| 315 |
-
|
| 316 |
-
{filteredGrantAchievements.length === 0 ? (
|
| 317 |
-
<div className="text-gray-400 text-sm mb-6">暂无成就,请先去“成就库管理”添加。</div>
|
| 318 |
-
) : (
|
| 319 |
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6 max-h-[400px] overflow-y-auto custom-scrollbar p-1">
|
| 320 |
{filteredGrantAchievements.map(ach => (
|
| 321 |
-
<div
|
| 322 |
-
key={ach.id}
|
| 323 |
-
onClick={() => setSelectedAchieveId(ach.id)}
|
| 324 |
-
className={`p-4 rounded-xl border cursor-pointer text-center transition-all ${selectedAchieveId === ach.id ? 'bg-amber-50 border-amber-400 ring-2 ring-amber-200' : 'bg-white border-gray-200 hover:bg-gray-50'}`}
|
| 325 |
-
>
|
| 326 |
<div className="text-3xl mb-1"><Emoji symbol={ach.icon} /></div>
|
| 327 |
<div className="font-bold text-sm text-gray-800">{ach.name}</div>
|
| 328 |
<div className="text-xs text-gray-500">+{ach.points} <Emoji symbol="🌺" size={12}/></div>
|
|
@@ -330,117 +212,37 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
|
|
| 330 |
))}
|
| 331 |
</div>
|
| 332 |
)}
|
| 333 |
-
|
| 334 |
<div className="mt-auto bg-gray-50 p-4 rounded-xl border border-gray-200">
|
| 335 |
-
<div className="flex justify-between items-center mb-2">
|
| 336 |
-
|
| 337 |
-
<span className="text-sm text-gray-600">已选: <b>{selectedStudents.size}</b> 人</span>
|
| 338 |
-
</div>
|
| 339 |
-
<button onClick={handleGrant} disabled={!selectedAchieveId || selectedStudents.size===0} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-md">
|
| 340 |
-
确认发放
|
| 341 |
-
</button>
|
| 342 |
</div>
|
| 343 |
</div>
|
| 344 |
</div>
|
| 345 |
)}
|
| 346 |
-
|
| 347 |
-
{/* 3. Independent Teacher Exchange Rules */}
|
| 348 |
{activeTab === 'exchange' && (
|
| 349 |
<div className="space-y-6 max-w-4xl mx-auto">
|
| 350 |
-
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100 text-sm text-purple-800 mb-4">
|
| 351 |
-
<span className="font-bold">提示:</span> 这里的兑换规则是您个人专属的,对您教的所有班级学生可见。学生兑换后,只有您能看到待处理的申请。
|
| 352 |
-
</div>
|
| 353 |
-
|
| 354 |
<div className="bg-green-50 p-4 rounded-xl border border-green-100 flex flex-col md:flex-row gap-4 items-end">
|
| 355 |
-
<div className="w-full md:w-32">
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
</div>
|
| 359 |
-
<div className="w-full md:w-32">
|
| 360 |
-
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励类型</label>
|
| 361 |
-
<select className="w-full border rounded p-2 text-sm bg-white" value={newRule.rewardType} onChange={e => setNewRule({...newRule, rewardType: e.target.value as any})}>
|
| 362 |
-
<option value="DRAW_COUNT">🎲 抽奖券</option>
|
| 363 |
-
<option value="ITEM">🎁 实物/特权</option>
|
| 364 |
-
</select>
|
| 365 |
-
</div>
|
| 366 |
-
<div className="flex-1 w-full">
|
| 367 |
-
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励名称</label>
|
| 368 |
-
<input className="w-full border rounded p-2 text-sm" placeholder="如: 免作业券" value={newRule.rewardName} onChange={e => setNewRule({...newRule, rewardName: e.target.value})}/>
|
| 369 |
-
</div>
|
| 370 |
-
<div className="w-24">
|
| 371 |
-
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">数量</label>
|
| 372 |
-
<input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newRule.rewardValue} onChange={e => setNewRule({...newRule, rewardValue: Number(e.target.value)})}/>
|
| 373 |
-
</div>
|
| 374 |
<button onClick={addRule} className="bg-green-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-green-700 whitespace-nowrap text-sm">添加规则</button>
|
| 375 |
</div>
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
<div className="flex items-center gap-4">
|
| 383 |
-
<div className="bg-amber-100 text-amber-700 font-bold px-3 py-1 rounded-lg border border-amber-200">
|
| 384 |
-
{rule.cost} <Emoji symbol="🌺" size={14}/>
|
| 385 |
-
</div>
|
| 386 |
-
<div className="text-gray-400">➡️</div>
|
| 387 |
-
<div className="flex items-center gap-2">
|
| 388 |
-
<span className="text-2xl"><Emoji symbol={rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} /></span>
|
| 389 |
-
<div>
|
| 390 |
-
<div className="font-bold text-gray-800">{rule.rewardName}</div>
|
| 391 |
-
<div className="text-xs text-gray-500">x{rule.rewardValue}</div>
|
| 392 |
-
</div>
|
| 393 |
-
</div>
|
| 394 |
-
</div>
|
| 395 |
-
<button onClick={() => deleteRule(rule.id)} className="text-gray-300 hover:text-red-500 p-2"><Trash2 size={18}/></button>
|
| 396 |
</div>
|
| 397 |
-
|
| 398 |
-
|
|
|
|
| 399 |
</div>
|
| 400 |
)}
|
| 401 |
-
|
| 402 |
-
{/* 4. Balance List (Only showing MY Rules for Proxy) */}
|
| 403 |
{activeTab === 'balance' && (
|
| 404 |
-
<div className="overflow-x-auto">
|
| 405 |
-
<table className="w-full text-left border-collapse">
|
| 406 |
-
<thead className="bg-gray-50 text-gray-500 text-xs uppercase">
|
| 407 |
-
<tr>
|
| 408 |
-
<th className="p-4">学生姓名</th>
|
| 409 |
-
<th className="p-4">小红花余额</th>
|
| 410 |
-
<th className="p-4 text-right">操作 (使用我的规则兑换)</th>
|
| 411 |
-
</tr>
|
| 412 |
-
</thead>
|
| 413 |
-
<tbody className="divide-y divide-gray-100">
|
| 414 |
-
{students.length === 0 ? (
|
| 415 |
-
<tr><td colSpan={3} className="p-4 text-center text-gray-400">暂无学生数据</td></tr>
|
| 416 |
-
) : students.map(s => (
|
| 417 |
-
<tr key={s._id} className="hover:bg-gray-50">
|
| 418 |
-
<td className="p-4 font-bold text-gray-700">{s.seatNo ? s.seatNo+'.':''}{s.name}</td>
|
| 419 |
-
<td className="p-4">
|
| 420 |
-
<span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">
|
| 421 |
-
{s.flowerBalance || 0} <Emoji symbol="🌺" size={14}/>
|
| 422 |
-
</span>
|
| 423 |
-
</td>
|
| 424 |
-
<td className="p-4 text-right">
|
| 425 |
-
<div className="flex justify-end gap-2">
|
| 426 |
-
{myRules.length > 0 ? myRules.map(r => (
|
| 427 |
-
<button
|
| 428 |
-
key={r.id}
|
| 429 |
-
disabled={(s.flowerBalance || 0) < r.cost}
|
| 430 |
-
onClick={() => handleProxyExchange(s._id || String(s.id), r.id)}
|
| 431 |
-
className="text-xs border border-gray-200 px-2 py-1 rounded hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 432 |
-
title={`消耗 ${r.cost} 花兑换 ${r.rewardName}`}
|
| 433 |
-
>
|
| 434 |
-
兑换 {r.rewardName} (-{r.cost})
|
| 435 |
-
</button>
|
| 436 |
-
)) : <span className="text-gray-300 text-xs">您没有设置规则</span>}
|
| 437 |
-
</div>
|
| 438 |
-
</td>
|
| 439 |
-
</tr>
|
| 440 |
-
))}
|
| 441 |
-
</tbody>
|
| 442 |
-
</table>
|
| 443 |
-
</div>
|
| 444 |
)}
|
| 445 |
</div>
|
| 446 |
</div>
|
|
|
|
| 4 |
import { AchievementConfig, AchievementItem, ExchangeRule, Student, TeacherExchangeConfig } from '../types';
|
| 5 |
import { Plus, Trash2, Edit, Save, Gift, Award, Coins, Users, Search, Loader2, CheckCircle, Filter } from 'lucide-react';
|
| 6 |
import { Emoji } from '../components/Emoji';
|
| 7 |
+
import { ConfirmModal } from '../components/ConfirmModal';
|
| 8 |
|
| 9 |
const PRESET_ICONS = [
|
| 10 |
{ icon: '🏆', label: '冠军杯' }, { icon: '🥇', label: '金牌' }, { icon: '🥈', label: '银牌' }, { icon: '🥉', label: '铜牌' },
|
|
|
|
| 20 |
const [myExchangeConfig, setMyExchangeConfig] = useState<TeacherExchangeConfig | null>(null);
|
| 21 |
const [students, setStudents] = useState<Student[]>([]);
|
| 22 |
const [semester, setSemester] = useState('');
|
|
|
|
|
|
|
| 23 |
const [activeTab, setActiveTab] = useState<'manage' | 'grant' | 'exchange' | 'balance'>('manage');
|
|
|
|
|
|
|
| 24 |
const [newAchieve, setNewAchieve] = useState<AchievementItem>({ id: '', name: '', icon: '🏆', points: 1 });
|
| 25 |
const [newRule, setNewRule] = useState<ExchangeRule>({ id: '', cost: 10, rewardType: 'DRAW_COUNT', rewardName: '抽奖券x1', rewardValue: 1 });
|
| 26 |
+
const [filterCreator, setFilterCreator] = useState('ALL');
|
| 27 |
+
const [filterGrantCreator, setFilterGrantCreator] = useState('ALL');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const [selectedStudents, setSelectedStudents] = useState<Set<string>>(new Set());
|
| 29 |
const [selectedAchieveId, setSelectedAchieveId] = useState('');
|
| 30 |
+
const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void}>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
|
| 31 |
|
| 32 |
const currentUser = api.auth.getCurrentUser();
|
| 33 |
const homeroomClass = className || currentUser?.homeroomClass;
|
| 34 |
+
const isHomeroom = currentUser?.homeroomClass === homeroomClass;
|
|
|
|
|
|
|
| 35 |
const isPrincipal = currentUser?.role === 'PRINCIPAL';
|
| 36 |
+
const canManageAll = isHomeroom || isPrincipal;
|
| 37 |
|
| 38 |
+
useEffect(() => { loadData(); }, [homeroomClass]);
|
|
|
|
|
|
|
| 39 |
|
| 40 |
const loadData = async () => {
|
| 41 |
if (!homeroomClass) return;
|
| 42 |
setLoading(true);
|
|
|
|
| 43 |
try {
|
|
|
|
| 44 |
const stus = await api.students.getAll();
|
| 45 |
+
const sortedStudents = stus.filter((s: Student) => s.className.trim() === homeroomClass.trim()).sort((a: Student, b: Student) => { const seatA = parseInt(a.seatNo || '99999'); const seatB = parseInt(b.seatNo || '99999'); if (seatA !== seatB) return seatA - seatB; return a.name.localeCompare(b.name, 'zh-CN'); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
setStudents(sortedStudents);
|
|
|
|
|
|
|
| 47 |
const [cfg, sysCfg, myRules] = await Promise.all([
|
| 48 |
api.achievements.getConfig(homeroomClass).catch(() => null),
|
| 49 |
api.config.getPublic().catch(() => ({ semester: '当前学期' })),
|
| 50 |
+
api.achievements.getMyRules().catch(() => ({ rules: [] }))
|
| 51 |
]);
|
| 52 |
+
const defaultConfig: AchievementConfig = { schoolId: currentUser?.schoolId || '', className: homeroomClass, achievements: [], exchangeRules: [] };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
setConfig(cfg || defaultConfig);
|
| 54 |
+
setMyExchangeConfig(myRules);
|
|
|
|
| 55 |
// @ts-ignore
|
| 56 |
setSemester(sysCfg?.semester || '当前学期');
|
| 57 |
+
} catch (e) { console.error("Failed to load data", e); } finally { setLoading(false); }
|
|
|
|
|
|
|
|
|
|
| 58 |
};
|
| 59 |
|
|
|
|
| 60 |
const handleSaveConfig = async (newConfig: AchievementConfig) => {
|
| 61 |
setConfig(newConfig);
|
| 62 |
+
try { await api.achievements.saveConfig(newConfig); } catch (e) { alert('保存失败'); loadData(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
};
|
| 64 |
|
|
|
|
| 65 |
const handleSaveMyRules = async (newRules: ExchangeRule[]) => {
|
| 66 |
+
const payload = { ...myExchangeConfig, rules: newRules } as TeacherExchangeConfig;
|
| 67 |
+
setMyExchangeConfig(payload);
|
| 68 |
+
try { await api.achievements.saveMyRules(payload); } catch(e) { alert('保存失败'); loadData(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
};
|
| 70 |
|
|
|
|
| 71 |
const addAchievement = () => {
|
| 72 |
if (!config) return;
|
| 73 |
if (!newAchieve.name) return alert('请输入成就名称');
|
| 74 |
+
const newItem: AchievementItem = { ...newAchieve, id: Date.now().toString(), addedBy: currentUser?._id, addedByName: currentUser?.trueName || currentUser?.username };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
const updated = { ...config, achievements: [...config.achievements, newItem] };
|
|
|
|
| 76 |
handleSaveConfig(updated);
|
| 77 |
setNewAchieve({ id: '', name: '', icon: '🏆', points: 1 });
|
| 78 |
};
|
| 79 |
|
| 80 |
const deleteAchievement = (item: AchievementItem) => {
|
| 81 |
if (!config) return;
|
|
|
|
| 82 |
if (!canManageAll && item.addedBy !== currentUser?._id) return alert('权限不足:只能删除自己添加的成就');
|
| 83 |
+
setConfirmModal({
|
| 84 |
+
isOpen: true,
|
| 85 |
+
title: '删除成就',
|
| 86 |
+
message: `确定要删除 "${item.name}" 吗?`,
|
| 87 |
+
onConfirm: () => {
|
| 88 |
+
const updated = { ...config, achievements: config.achievements.filter(a => a.id !== item.id) };
|
| 89 |
+
handleSaveConfig(updated);
|
| 90 |
+
}
|
| 91 |
+
});
|
| 92 |
};
|
| 93 |
|
|
|
|
| 94 |
const handleGrant = async () => {
|
| 95 |
if (selectedStudents.size === 0 || !selectedAchieveId) return alert('请选择学生和奖状');
|
| 96 |
try {
|
| 97 |
+
const promises = Array.from(selectedStudents).map(sid => api.achievements.grant({ studentId: sid, achievementId: selectedAchieveId, semester }));
|
|
|
|
|
|
|
| 98 |
await Promise.all(promises);
|
| 99 |
alert(`成功发放给 ${selectedStudents.size} 位学生`);
|
| 100 |
setSelectedStudents(new Set());
|
| 101 |
+
loadData();
|
| 102 |
} catch (e) { alert('部分发放失败'); }
|
| 103 |
};
|
| 104 |
|
|
|
|
| 105 |
const addRule = () => {
|
| 106 |
if (!newRule.rewardName) return alert('请输入奖励名称');
|
| 107 |
const newItem = { ...newRule, id: Date.now().toString() };
|
|
|
|
| 111 |
};
|
| 112 |
|
| 113 |
const deleteRule = (id: string) => {
|
| 114 |
+
setConfirmModal({
|
| 115 |
+
isOpen: true,
|
| 116 |
+
title: '删除规则',
|
| 117 |
+
message: '确定要删除这条兑换规则吗?',
|
| 118 |
+
onConfirm: () => {
|
| 119 |
+
const currentRules = myExchangeConfig?.rules || [];
|
| 120 |
+
handleSaveMyRules(currentRules.filter(r => r.id !== id));
|
| 121 |
+
}
|
| 122 |
+
});
|
| 123 |
};
|
| 124 |
|
|
|
|
| 125 |
const handleProxyExchange = async (studentId: string, ruleId: string) => {
|
| 126 |
+
setConfirmModal({
|
| 127 |
+
isOpen: true,
|
| 128 |
+
title: '代兑换',
|
| 129 |
+
message: '确认代学生兑换?将扣除对应小红花并记录。',
|
| 130 |
+
onConfirm: async () => {
|
| 131 |
+
try {
|
| 132 |
+
await api.achievements.exchange({ studentId, ruleId, teacherId: currentUser?._id });
|
| 133 |
+
alert('兑换成功');
|
| 134 |
+
loadData();
|
| 135 |
+
} catch (e: any) { alert(e.message || '兑换失败,余额不足?'); }
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
};
|
| 139 |
|
| 140 |
if (!homeroomClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法使用成就管理功能。</div>;
|
|
|
|
| 142 |
|
| 143 |
const safeConfig = config || { achievements: [], exchangeRules: [] };
|
| 144 |
const myRules = myExchangeConfig?.rules || [];
|
|
|
|
|
|
|
| 145 |
const uniqueCreators = Array.from(new Set(safeConfig.achievements.map(a => a.addedByName || '未知'))).filter(Boolean);
|
| 146 |
const filteredAchievements = safeConfig.achievements.filter(a => filterCreator === 'ALL' || a.addedByName === filterCreator);
|
| 147 |
const filteredGrantAchievements = safeConfig.achievements.filter(a => filterGrantCreator === 'ALL' || a.addedByName === filterGrantCreator);
|
| 148 |
|
| 149 |
return (
|
| 150 |
<div className="h-full flex flex-col bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
| 151 |
+
<ConfirmModal isOpen={confirmModal.isOpen} title={confirmModal.title} message={confirmModal.message} onClose={()=>setConfirmModal({...confirmModal, isOpen: false})} onConfirm={confirmModal.onConfirm}/>
|
| 152 |
<div className="flex border-b border-gray-100 bg-gray-50/50 overflow-x-auto">
|
| 153 |
+
<button onClick={() => setActiveTab('manage')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'manage' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Award size={18}/> 成就库管理</button>
|
| 154 |
+
<button onClick={() => setActiveTab('grant')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'grant' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Gift size={18}/> 发放成就</button>
|
| 155 |
+
<button onClick={() => setActiveTab('exchange')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'exchange' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Coins size={18}/> 兑换规则 (我的)</button>
|
| 156 |
+
<button onClick={() => setActiveTab('balance')} className={`px-6 py-4 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 whitespace-nowrap ${activeTab === 'balance' ? 'border-blue-500 text-blue-600 bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Users size={18}/> 学生积分</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
</div>
|
|
|
|
| 158 |
<div className="flex-1 overflow-y-auto p-6">
|
|
|
|
| 159 |
{activeTab === 'manage' && (
|
| 160 |
<div className="space-y-6 max-w-4xl mx-auto">
|
| 161 |
+
<div className="flex justify-end items-center gap-2"><span className="text-xs font-bold text-gray-500"><Filter size={14} className="inline mr-1"/>筛选添加者:</span><select className="border rounded text-xs p-1" value={filterCreator} onChange={e=>setFilterCreator(e.target.value)}><option value="ALL">全部老师</option>{uniqueCreators.map(c => <option key={c} value={c}>{c}</option>)}</select></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100 flex flex-col md:flex-row gap-4 items-end">
|
| 163 |
+
<div className="flex-1 w-full"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">成就名称</label><input className="w-full border rounded p-2 text-sm" placeholder="如: 劳动小能手" value={newAchieve.name} onChange={e => setNewAchieve({...newAchieve, name: e.target.value})} /></div>
|
| 164 |
+
<div className="w-full md:w-32"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">分值 (小红花)</label><input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newAchieve.points} onChange={e => setNewAchieve({...newAchieve, points: Number(e.target.value)})} /></div>
|
| 165 |
+
<div className="w-full md:w-auto"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">图标预设</label><select className="w-full border rounded p-2 min-w-[100px] text-sm bg-white" value={newAchieve.icon} onChange={e => setNewAchieve({...newAchieve, icon: e.target.value})}>{PRESET_ICONS.map(p => <option key={p.label} value={p.icon}>{p.icon} {p.label}</option>)}</select></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
<button onClick={addAchievement} className="bg-blue-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-blue-700 whitespace-nowrap text-sm">添加成就</button>
|
| 167 |
</div>
|
| 168 |
+
{filteredAchievements.length === 0 ? <div className="text-center text-gray-400 py-10">暂无成就,请添加</div> : (
|
|
|
|
|
|
|
|
|
|
| 169 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 170 |
{filteredAchievements.map(ach => {
|
| 171 |
const isMine = ach.addedBy === currentUser?._id;
|
|
|
|
| 174 |
<div key={ach.id} className="border border-gray-200 rounded-xl p-4 flex flex-col items-center relative group hover:shadow-md transition-all bg-white">
|
| 175 |
<div className="text-4xl mb-2"><Emoji symbol={ach.icon} /></div>
|
| 176 |
<div className="font-bold text-gray-800 text-center">{ach.name}</div>
|
| 177 |
+
<div className="text-xs text-amber-600 font-bold bg-amber-50 px-2 py-0.5 rounded-full mt-1 border border-amber-100">{ach.points} <Emoji symbol="🌺" size={14}/></div>
|
| 178 |
+
<div className="mt-2 text-[10px] text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded">{ach.addedByName || '未知'}</div>
|
| 179 |
+
{canDelete && <button onClick={() => deleteAchievement(ach)} className="absolute top-2 right-2 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={16}/></button>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
</div>
|
| 181 |
)})}
|
| 182 |
</div>
|
| 183 |
)}
|
| 184 |
</div>
|
| 185 |
)}
|
|
|
|
|
|
|
| 186 |
{activeTab === 'grant' && (
|
| 187 |
<div className="flex flex-col md:flex-row gap-6 h-full">
|
|
|
|
| 188 |
<div className="w-full md:w-1/3 border-r border-gray-100 pr-4 flex flex-col min-h-[400px]">
|
| 189 |
<h3 className="font-bold text-gray-700 mb-2">1. 选择学生 ({selectedStudents.size})</h3>
|
| 190 |
+
<button onClick={() => setSelectedStudents(new Set(selectedStudents.size === students.length ? [] : students.map(s => s._id || String(s.id))))} className="text-xs text-blue-600 mb-2 text-left hover:underline">{selectedStudents.size === students.length ? '取消全选' : '全选所有'}</button>
|
| 191 |
+
{students.length === 0 ? <div className="text-sm text-gray-400">班级暂无学生数据</div> : (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
<div className="flex-1 overflow-y-auto space-y-2 pr-2 custom-scrollbar max-h-[500px]">
|
| 193 |
{students.map(s => (
|
| 194 |
+
<div key={s._id} onClick={() => { const newSet = new Set(selectedStudents); const sid = s._id || String(s.id); if (newSet.has(sid)) newSet.delete(sid); else newSet.add(sid); setSelectedStudents(newSet); }} className={`p-2 rounded border cursor-pointer flex items-center justify-between transition-colors ${selectedStudents.has(s._id || String(s.id)) ? 'bg-blue-50 border-blue-400' : 'bg-white border-gray-200 hover:bg-gray-50'}`}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
<span className="text-sm font-medium">{s.seatNo ? s.seatNo+'.':''}{s.name}</span>
|
| 196 |
{selectedStudents.has(s._id || String(s.id)) && <CheckCircle size={16} className="text-blue-500"/>}
|
| 197 |
</div>
|
|
|
|
| 199 |
</div>
|
| 200 |
)}
|
| 201 |
</div>
|
|
|
|
|
|
|
| 202 |
<div className="flex-1 flex flex-col">
|
| 203 |
+
<div className="flex justify-between items-center mb-4"><h3 className="font-bold text-gray-700">2. 选择要颁发的奖状</h3><div className="flex items-center gap-2"><span className="text-xs text-gray-500">来自:</span><select className="border rounded text-xs p-1" value={filterGrantCreator} onChange={e=>setFilterGrantCreator(e.target.value)}><option value="ALL">全部老师</option>{uniqueCreators.map(c => <option key={c} value={c}>{c}</option>)}</select></div></div>
|
| 204 |
+
{filteredGrantAchievements.length === 0 ? <div className="text-gray-400 text-sm mb-6">暂无成就,请先去“成就库管理”添加。</div> : (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6 max-h-[400px] overflow-y-auto custom-scrollbar p-1">
|
| 206 |
{filteredGrantAchievements.map(ach => (
|
| 207 |
+
<div key={ach.id} onClick={() => setSelectedAchieveId(ach.id)} className={`p-4 rounded-xl border cursor-pointer text-center transition-all ${selectedAchieveId === ach.id ? 'bg-amber-50 border-amber-400 ring-2 ring-amber-200' : 'bg-white border-gray-200 hover:bg-gray-50'}`}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
<div className="text-3xl mb-1"><Emoji symbol={ach.icon} /></div>
|
| 209 |
<div className="font-bold text-sm text-gray-800">{ach.name}</div>
|
| 210 |
<div className="text-xs text-gray-500">+{ach.points} <Emoji symbol="🌺" size={12}/></div>
|
|
|
|
| 212 |
))}
|
| 213 |
</div>
|
| 214 |
)}
|
|
|
|
| 215 |
<div className="mt-auto bg-gray-50 p-4 rounded-xl border border-gray-200">
|
| 216 |
+
<div className="flex justify-between items-center mb-2"><span className="text-sm text-gray-600">当前学期: <b>{semester}</b></span><span className="text-sm text-gray-600">已选: <b>{selectedStudents.size}</b> 人</span></div>
|
| 217 |
+
<button onClick={handleGrant} disabled={!selectedAchieveId || selectedStudents.size===0} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-md">确认发放</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
</div>
|
| 219 |
</div>
|
| 220 |
</div>
|
| 221 |
)}
|
|
|
|
|
|
|
| 222 |
{activeTab === 'exchange' && (
|
| 223 |
<div className="space-y-6 max-w-4xl mx-auto">
|
| 224 |
+
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100 text-sm text-purple-800 mb-4"><span className="font-bold">提示:</span> 这里的兑换规则是您个人专属的,对您教的所有班级学生可见。学生兑换后,只有您能看到待处理的申请。</div>
|
|
|
|
|
|
|
|
|
|
| 225 |
<div className="bg-green-50 p-4 rounded-xl border border-green-100 flex flex-col md:flex-row gap-4 items-end">
|
| 226 |
+
<div className="w-full md:w-32"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">消耗小红花</label><input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newRule.cost} onChange={e => setNewRule({...newRule,cost: Number(e.target.value)})}/></div>
|
| 227 |
+
<div className="w-full md:w-32"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励类型</label><select className="w-full border rounded p-2 text-sm bg-white" value={newRule.rewardType} onChange={e => setNewRule({...newRule, rewardType: e.target.value as any})}><option value="DRAW_COUNT">🎲 抽奖券</option><option value="ITEM">🎁 实物/特权</option></select></div>
|
| 228 |
+
<div className="flex-1 w-full"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">奖励名称</label><input className="w-full border rounded p-2 text-sm" placeholder="如: 免作业券" value={newRule.rewardName} onChange={e => setNewRule({...newRule, rewardName: e.target.value})}/></div>
|
| 229 |
+
<div className="w-24"><label className="text-xs font-bold text-gray-500 uppercase mb-1 block">数量</label><input type="number" min={1} className="w-full border rounded p-2 text-sm" value={newRule.rewardValue} onChange={e => setNewRule({...newRule, rewardValue: Number(e.target.value)})}/></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
<button onClick={addRule} className="bg-green-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-green-700 whitespace-nowrap text-sm">添加规则</button>
|
| 231 |
</div>
|
| 232 |
+
<div className="space-y-3">{myRules.length === 0 ? <div className="text-center text-gray-400 py-4">暂无个人兑换规则</div> : myRules.map(rule => (
|
| 233 |
+
<div key={rule.id} className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-xl hover:shadow-sm">
|
| 234 |
+
<div className="flex items-center gap-4">
|
| 235 |
+
<div className="bg-amber-100 text-amber-700 font-bold px-3 py-1 rounded-lg border border-amber-200">{rule.cost} <Emoji symbol="🌺" size={14}/></div>
|
| 236 |
+
<div className="text-gray-400">➡️</div>
|
| 237 |
+
<div className="flex items-center gap-2"><span className="text-2xl"><Emoji symbol={rule.rewardType === 'DRAW_COUNT' ? '🎲' : '🎁'} /></span><div><div className="font-bold text-gray-800">{rule.rewardName}</div><div className="text-xs text-gray-500">x{rule.rewardValue}</div></div></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
</div>
|
| 239 |
+
<button onClick={() => deleteRule(rule.id)} className="text-gray-300 hover:text-red-500 p-2"><Trash2 size={18}/></button>
|
| 240 |
+
</div>
|
| 241 |
+
))}</div>
|
| 242 |
</div>
|
| 243 |
)}
|
|
|
|
|
|
|
| 244 |
{activeTab === 'balance' && (
|
| 245 |
+
<div className="overflow-x-auto"><table className="w-full text-left border-collapse"><thead className="bg-gray-50 text-gray-500 text-xs uppercase"><tr><th className="p-4">学生姓名</th><th className="p-4">小红花余额</th><th className="p-4 text-right">操作 (使用我的规则兑换)</th></tr></thead><tbody className="divide-y divide-gray-100">{students.length === 0 ? <tr><td colSpan={3} className="p-4 text-center text-gray-400">暂无学生数据</td></tr> : students.map(s => (<tr key={s._id} className="hover:bg-gray-50"><td className="p-4 font-bold text-gray-700">{s.seatNo ? s.seatNo+'.':''}{s.name}</td><td className="p-4"><span className="text-amber-600 font-bold bg-amber-50 px-2 py-1 rounded border border-amber-100">{s.flowerBalance || 0} <Emoji symbol="🌺" size={14}/></span></td><td className="p-4 text-right"><div className="flex justify-end gap-2">{myRules.length > 0 ? myRules.map(r => (<button key={r.id} disabled={(s.flowerBalance || 0) < r.cost} onClick={() => handleProxyExchange(s._id || String(s.id), r.id)} className="text-xs border border-gray-200 px-2 py-1 rounded hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-30 disabled:cursor-not-allowed" title={`消耗 ${r.cost} 花兑换 ${r.rewardName}`}>兑换 {r.rewardName} (-{r.cost})</button>)) : <span className="text-gray-300 text-xs">您没有设置规则</span>}</div></td></tr>))}</tbody></table></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
)}
|
| 247 |
</div>
|
| 248 |
</div>
|
pages/Attendance.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Student, Attendance, SchoolCalendarEntry } from '../types';
|
| 5 |
-
import { Calendar, CheckCircle, Clock, AlertCircle, Loader2, UserX, CalendarOff, Settings, Plus, Trash2, Sun } from 'lucide-react';
|
| 6 |
|
| 7 |
export const AttendancePage: React.FC = () => {
|
| 8 |
const [students, setStudents] = useState<Student[]>([]);
|
|
@@ -10,14 +10,12 @@ export const AttendancePage: React.FC = () => {
|
|
| 10 |
const [loading, setLoading] = useState(true);
|
| 11 |
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
| 12 |
|
| 13 |
-
// Calendar State
|
| 14 |
const [calendarEntries, setCalendarEntries] = useState<SchoolCalendarEntry[]>([]);
|
| 15 |
const [isHoliday, setIsHoliday] = useState(false);
|
| 16 |
const [holidayName, setHolidayName] = useState('');
|
| 17 |
|
| 18 |
-
// Settings Modal
|
| 19 |
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 20 |
-
const [excludeWeekends, setExcludeWeekends] = useState(true);
|
| 21 |
const [newHoliday, setNewHoliday] = useState({ name: '', startDate: '', endDate: '' });
|
| 22 |
|
| 23 |
const currentUser = api.auth.getCurrentUser();
|
|
@@ -33,15 +31,12 @@ export const AttendancePage: React.FC = () => {
|
|
| 33 |
api.calendar.get(targetClass)
|
| 34 |
]);
|
| 35 |
const classStudents = stus.filter((s: Student) => s.className === targetClass);
|
| 36 |
-
|
| 37 |
-
// SORTING LOGIC: Seat No > Name
|
| 38 |
classStudents.sort((a: Student, b: Student) => {
|
| 39 |
const seatA = parseInt(a.seatNo || '99999');
|
| 40 |
const seatB = parseInt(b.seatNo || '99999');
|
| 41 |
if (seatA !== seatB) return seatA - seatB;
|
| 42 |
return a.name.localeCompare(b.name, 'zh-CN');
|
| 43 |
});
|
| 44 |
-
|
| 45 |
setStudents(classStudents);
|
| 46 |
setCalendarEntries(cals);
|
| 47 |
|
|
@@ -55,40 +50,39 @@ export const AttendancePage: React.FC = () => {
|
|
| 55 |
finally { setLoading(false); }
|
| 56 |
};
|
| 57 |
|
| 58 |
-
useEffect(() => {
|
| 59 |
-
loadData();
|
| 60 |
-
}, [date]);
|
| 61 |
|
| 62 |
-
// Check if selected date is a holiday or weekend
|
| 63 |
const checkIsHoliday = (checkDate: string, entries: SchoolCalendarEntry[]) => {
|
| 64 |
const d = new Date(checkDate);
|
| 65 |
-
const day = d.getDay();
|
| 66 |
|
| 67 |
-
// 1. Check
|
| 68 |
-
if (excludeWeekends && (day === 0 || day === 6)) {
|
| 69 |
-
setIsHoliday(true);
|
| 70 |
-
setHolidayName(day === 0 ? '周日' : '周六');
|
| 71 |
-
return;
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
// 2. Check Calendar Entries
|
| 75 |
const entry = entries.find(e => {
|
| 76 |
return checkDate >= e.startDate && checkDate <= e.endDate;
|
| 77 |
});
|
| 78 |
|
| 79 |
if (entry) {
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
} else {
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
};
|
| 87 |
|
| 88 |
-
|
| 89 |
-
useEffect(() => {
|
| 90 |
-
checkIsHoliday(date, calendarEntries);
|
| 91 |
-
}, [excludeWeekends]);
|
| 92 |
|
| 93 |
const handleBatchCheckIn = async () => {
|
| 94 |
if (isHoliday) return alert('当前是非考勤日,无法执行全勤操作');
|
|
@@ -98,16 +92,13 @@ export const AttendancePage: React.FC = () => {
|
|
| 98 |
};
|
| 99 |
|
| 100 |
const toggleStatus = async (studentId: string) => {
|
| 101 |
-
if (isHoliday) return;
|
| 102 |
const current = attendanceMap[studentId]?.status;
|
| 103 |
let nextStatus = 'Present';
|
| 104 |
if (current === 'Present') nextStatus = 'Leave';
|
| 105 |
else if (current === 'Leave') nextStatus = 'Absent';
|
| 106 |
else if (current === 'Absent') nextStatus = 'Present';
|
| 107 |
-
|
| 108 |
-
// If no record, create as Present first
|
| 109 |
if (!current) nextStatus = 'Present';
|
| 110 |
-
|
| 111 |
await api.attendance.update({ studentId, date, status: nextStatus });
|
| 112 |
loadData();
|
| 113 |
};
|
|
@@ -116,33 +107,47 @@ export const AttendancePage: React.FC = () => {
|
|
| 116 |
if (!newHoliday.name || !newHoliday.startDate || !newHoliday.endDate) return alert('请填写完整');
|
| 117 |
await api.calendar.add({
|
| 118 |
schoolId: currentUser?.schoolId!,
|
| 119 |
-
className: targetClass,
|
| 120 |
type: 'HOLIDAY',
|
| 121 |
...newHoliday
|
| 122 |
});
|
| 123 |
setNewHoliday({ name: '', startDate: '', endDate: '' });
|
| 124 |
-
loadData();
|
| 125 |
};
|
| 126 |
|
| 127 |
const handleDeleteHoliday = async (id: string) => {
|
| 128 |
-
if(confirm('删除此
|
| 129 |
await api.calendar.delete(id);
|
| 130 |
loadData();
|
| 131 |
}
|
| 132 |
};
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
await api.calendar.add({
|
| 137 |
schoolId: currentUser?.schoolId!,
|
| 138 |
className: targetClass,
|
| 139 |
type: 'OFF',
|
| 140 |
startDate: date,
|
| 141 |
endDate: date,
|
| 142 |
-
name: '
|
| 143 |
});
|
| 144 |
-
loadData();
|
| 145 |
}
|
|
|
|
| 146 |
};
|
| 147 |
|
| 148 |
if (!targetClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法管理考勤。</div>;
|
|
@@ -157,7 +162,6 @@ export const AttendancePage: React.FC = () => {
|
|
| 157 |
|
| 158 |
return (
|
| 159 |
<div className="space-y-6">
|
| 160 |
-
{/* Top Bar */}
|
| 161 |
<div className="flex flex-col md:flex-row justify-between items-center bg-white p-4 rounded-xl shadow-sm border border-gray-100 gap-4">
|
| 162 |
<div className="flex items-center gap-4 w-full md:w-auto">
|
| 163 |
<h2 className="text-xl font-bold text-gray-800">考勤管理</h2>
|
|
@@ -173,43 +177,34 @@ export const AttendancePage: React.FC = () => {
|
|
| 173 |
|
| 174 |
<div className="flex gap-2 items-center w-full md:w-auto">
|
| 175 |
<input type="date" className="border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500" value={date} onChange={e => setDate(e.target.value)} max={new Date().toISOString().split('T')[0]}/>
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
| 187 |
)}
|
|
|
|
| 188 |
<button onClick={() => setIsSettingsOpen(true)} className="bg-gray-100 text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-200 text-sm whitespace-nowrap">
|
| 189 |
<Settings size={18}/>
|
| 190 |
</button>
|
| 191 |
</div>
|
| 192 |
</div>
|
| 193 |
|
| 194 |
-
{/* Stats Cards */}
|
| 195 |
{!isHoliday && (
|
| 196 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in slide-in-from-top-2">
|
| 197 |
-
<div className="bg-green-50 p-4 rounded-lg border border-green-100 flex flex-col items-center">
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
</div>
|
| 201 |
-
<div className="bg-orange-50 p-4 rounded-lg border border-orange-100 flex flex-col items-center">
|
| 202 |
-
<span className="text-orange-500 font-bold text-2xl">{stats.leave}</span>
|
| 203 |
-
<span className="text-xs text-orange-700">请假</span>
|
| 204 |
-
</div>
|
| 205 |
-
<div className="bg-red-50 p-4 rounded-lg border border-red-100 flex flex-col items-center">
|
| 206 |
-
<span className="text-red-500 font-bold text-2xl">{stats.absent}</span>
|
| 207 |
-
<span className="text-xs text-red-700">缺勤</span>
|
| 208 |
-
</div>
|
| 209 |
-
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 flex flex-col items-center">
|
| 210 |
-
<span className="text-gray-500 font-bold text-2xl">{missing}</span>
|
| 211 |
-
<span className="text-xs text-gray-600">未记录</span>
|
| 212 |
-
</div>
|
| 213 |
</div>
|
| 214 |
)}
|
| 215 |
|
|
@@ -218,7 +213,6 @@ export const AttendancePage: React.FC = () => {
|
|
| 218 |
{students.map(s => {
|
| 219 |
const record = attendanceMap[s._id || String(s.id)];
|
| 220 |
const status = record?.status || 'None';
|
| 221 |
-
|
| 222 |
let bg = 'bg-white border-gray-200';
|
| 223 |
let icon = <Clock size={20} className="text-gray-300"/>;
|
| 224 |
let text = '未打卡';
|
|
@@ -247,71 +241,31 @@ export const AttendancePage: React.FC = () => {
|
|
| 247 |
}
|
| 248 |
|
| 249 |
return (
|
| 250 |
-
<div
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
className={`
|
| 254 |
-
|
| 255 |
-
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold text-white ${s.gender==='Female'?'bg-pink-400':'bg-blue-400'} ${isHoliday ? 'grayscale opacity-50' : ''}`}>
|
| 256 |
-
{s.name[0]}
|
| 257 |
-
</div>
|
| 258 |
-
<div className="text-center">
|
| 259 |
-
<p className="font-bold text-gray-800 text-sm">{s.name}</p>
|
| 260 |
-
<p className="text-[10px] text-gray-400">{s.seatNo ? `${s.seatNo}号` : s.studentNo}</p>
|
| 261 |
-
</div>
|
| 262 |
-
<div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>
|
| 263 |
-
{icon} {text}
|
| 264 |
-
</div>
|
| 265 |
-
{!isHoliday && (
|
| 266 |
-
<div className="absolute inset-0 bg-black/5 opacity-0 group-hover:opacity-100 rounded-xl transition-opacity flex items-center justify-center text-xs text-gray-600 font-bold">
|
| 267 |
-
点击切换
|
| 268 |
-
</div>
|
| 269 |
-
)}
|
| 270 |
</div>
|
| 271 |
);
|
| 272 |
})}
|
| 273 |
</div>
|
| 274 |
)}
|
| 275 |
|
| 276 |
-
{/* Calendar Settings Modal */}
|
| 277 |
{isSettingsOpen && (
|
| 278 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 279 |
<div className="bg-white rounded-xl w-full max-w-lg p-6 shadow-2xl animate-in zoom-in-95">
|
| 280 |
<h3 className="font-bold text-lg mb-4 flex items-center"><Calendar className="mr-2"/> 考勤日历设置</h3>
|
| 281 |
-
|
| 282 |
<div className="space-y-6">
|
| 283 |
-
<div className="bg-blue-50 p-4 rounded-lg flex justify-between items-center">
|
| 284 |
-
<div>
|
| 285 |
-
<p className="font-bold text-blue-800 text-sm">周末自动免打卡</p>
|
| 286 |
-
<p className="text-xs text-blue-600">开启后,周六和周日将自动标记为休息日</p>
|
| 287 |
-
</div>
|
| 288 |
-
<input type="checkbox" checked={excludeWeekends} onChange={e => setExcludeWeekends(e.target.checked)} className="w-5 h-5"/>
|
| 289 |
-
</div>
|
| 290 |
-
|
| 291 |
<div>
|
| 292 |
-
<label className="text-sm font-bold text-gray-700 mb-2 block">自定义假期
|
| 293 |
-
<div className="flex gap-2 mb-3">
|
| 294 |
-
<input className="border rounded p-2 text-xs flex-1" placeholder="假期名称 (如: 国庆节)" value={newHoliday.name} onChange={e=>setNewHoliday({...newHoliday, name:e.target.value})}/>
|
| 295 |
-
<input type="date" className="border rounded p-2 text-xs" value={newHoliday.startDate} onChange={e=>setNewHoliday({...newHoliday, startDate:e.target.value})}/>
|
| 296 |
-
<span className="self-center text-gray-400">-</span>
|
| 297 |
-
<input type="date" className="border rounded p-2 text-xs" value={newHoliday.endDate} onChange={e=>setNewHoliday({...newHoliday, endDate:e.target.value})}/>
|
| 298 |
-
<button onClick={handleAddHoliday} className="bg-green-600 text-white p-2 rounded hover:bg-green-700"><Plus size={16}/></button>
|
| 299 |
-
</div>
|
| 300 |
-
|
| 301 |
<div className="max-h-40 overflow-y-auto border rounded bg-gray-50 custom-scrollbar">
|
| 302 |
-
{calendarEntries.length > 0 ? calendarEntries.map(c => (
|
| 303 |
-
<div key={c._id} className="flex justify-between items-center p-2 border-b last:border-0 hover:bg-white text-sm">
|
| 304 |
-
<div>
|
| 305 |
-
<span className="font-bold text-gray-800 mr-2">{c.name}</span>
|
| 306 |
-
<span className="text-xs text-gray-500">{c.startDate} ~ {c.endDate}</span>
|
| 307 |
-
</div>
|
| 308 |
-
<button onClick={() => handleDeleteHoliday(c._id!)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button>
|
| 309 |
-
</div>
|
| 310 |
-
)) : <p className="text-center text-gray-400 text-xs py-4">暂无自定义假期</p>}
|
| 311 |
</div>
|
| 312 |
</div>
|
| 313 |
</div>
|
| 314 |
-
|
| 315 |
<button onClick={() => setIsSettingsOpen(false)} className="mt-6 w-full py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 font-bold">关闭</button>
|
| 316 |
</div>
|
| 317 |
</div>
|
|
|
|
| 2 |
import React, { useState, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Student, Attendance, SchoolCalendarEntry } from '../types';
|
| 5 |
+
import { Calendar, CheckCircle, Clock, AlertCircle, Loader2, UserX, CalendarOff, Settings, Plus, Trash2, Sun, Briefcase } from 'lucide-react';
|
| 6 |
|
| 7 |
export const AttendancePage: React.FC = () => {
|
| 8 |
const [students, setStudents] = useState<Student[]>([]);
|
|
|
|
| 10 |
const [loading, setLoading] = useState(true);
|
| 11 |
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
| 12 |
|
|
|
|
| 13 |
const [calendarEntries, setCalendarEntries] = useState<SchoolCalendarEntry[]>([]);
|
| 14 |
const [isHoliday, setIsHoliday] = useState(false);
|
| 15 |
const [holidayName, setHolidayName] = useState('');
|
| 16 |
|
|
|
|
| 17 |
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 18 |
+
const [excludeWeekends, setExcludeWeekends] = useState(true);
|
| 19 |
const [newHoliday, setNewHoliday] = useState({ name: '', startDate: '', endDate: '' });
|
| 20 |
|
| 21 |
const currentUser = api.auth.getCurrentUser();
|
|
|
|
| 31 |
api.calendar.get(targetClass)
|
| 32 |
]);
|
| 33 |
const classStudents = stus.filter((s: Student) => s.className === targetClass);
|
|
|
|
|
|
|
| 34 |
classStudents.sort((a: Student, b: Student) => {
|
| 35 |
const seatA = parseInt(a.seatNo || '99999');
|
| 36 |
const seatB = parseInt(b.seatNo || '99999');
|
| 37 |
if (seatA !== seatB) return seatA - seatB;
|
| 38 |
return a.name.localeCompare(b.name, 'zh-CN');
|
| 39 |
});
|
|
|
|
| 40 |
setStudents(classStudents);
|
| 41 |
setCalendarEntries(cals);
|
| 42 |
|
|
|
|
| 50 |
finally { setLoading(false); }
|
| 51 |
};
|
| 52 |
|
| 53 |
+
useEffect(() => { loadData(); }, [date]);
|
|
|
|
|
|
|
| 54 |
|
|
|
|
| 55 |
const checkIsHoliday = (checkDate: string, entries: SchoolCalendarEntry[]) => {
|
| 56 |
const d = new Date(checkDate);
|
| 57 |
+
const day = d.getDay();
|
| 58 |
|
| 59 |
+
// 1. Check for Explicit Calendar Entries (High Priority)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
const entry = entries.find(e => {
|
| 61 |
return checkDate >= e.startDate && checkDate <= e.endDate;
|
| 62 |
});
|
| 63 |
|
| 64 |
if (entry) {
|
| 65 |
+
// If WORKDAY, it is NOT a holiday, even if weekend
|
| 66 |
+
if (entry.type === 'WORKDAY') {
|
| 67 |
+
setIsHoliday(false);
|
| 68 |
+
setHolidayName('');
|
| 69 |
+
} else {
|
| 70 |
+
setIsHoliday(true);
|
| 71 |
+
setHolidayName(entry.name);
|
| 72 |
+
}
|
| 73 |
} else {
|
| 74 |
+
// 2. Fallback to Weekend Check
|
| 75 |
+
if (excludeWeekends && (day === 0 || day === 6)) {
|
| 76 |
+
setIsHoliday(true);
|
| 77 |
+
setHolidayName(day === 0 ? '周日' : '周六');
|
| 78 |
+
} else {
|
| 79 |
+
setIsHoliday(false);
|
| 80 |
+
setHolidayName('');
|
| 81 |
+
}
|
| 82 |
}
|
| 83 |
};
|
| 84 |
|
| 85 |
+
useEffect(() => { checkIsHoliday(date, calendarEntries); }, [excludeWeekends]);
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
const handleBatchCheckIn = async () => {
|
| 88 |
if (isHoliday) return alert('当前是非考勤日,无法执行全勤操作');
|
|
|
|
| 92 |
};
|
| 93 |
|
| 94 |
const toggleStatus = async (studentId: string) => {
|
| 95 |
+
if (isHoliday) return;
|
| 96 |
const current = attendanceMap[studentId]?.status;
|
| 97 |
let nextStatus = 'Present';
|
| 98 |
if (current === 'Present') nextStatus = 'Leave';
|
| 99 |
else if (current === 'Leave') nextStatus = 'Absent';
|
| 100 |
else if (current === 'Absent') nextStatus = 'Present';
|
|
|
|
|
|
|
| 101 |
if (!current) nextStatus = 'Present';
|
|
|
|
| 102 |
await api.attendance.update({ studentId, date, status: nextStatus });
|
| 103 |
loadData();
|
| 104 |
};
|
|
|
|
| 107 |
if (!newHoliday.name || !newHoliday.startDate || !newHoliday.endDate) return alert('请填写完整');
|
| 108 |
await api.calendar.add({
|
| 109 |
schoolId: currentUser?.schoolId!,
|
| 110 |
+
className: targetClass,
|
| 111 |
type: 'HOLIDAY',
|
| 112 |
...newHoliday
|
| 113 |
});
|
| 114 |
setNewHoliday({ name: '', startDate: '', endDate: '' });
|
| 115 |
+
loadData();
|
| 116 |
};
|
| 117 |
|
| 118 |
const handleDeleteHoliday = async (id: string) => {
|
| 119 |
+
if(confirm('删除此设置?')) {
|
| 120 |
await api.calendar.delete(id);
|
| 121 |
loadData();
|
| 122 |
}
|
| 123 |
};
|
| 124 |
|
| 125 |
+
// Toggle Day Type (Workday <-> Holiday/Off)
|
| 126 |
+
const toggleDayType = async () => {
|
| 127 |
+
if (isHoliday) {
|
| 128 |
+
// Current is Holiday -> Set to Workday
|
| 129 |
+
if (!confirm(`确定将 ${date} 设为“工作日”并开启考勤吗?`)) return;
|
| 130 |
+
await api.calendar.add({
|
| 131 |
+
schoolId: currentUser?.schoolId!,
|
| 132 |
+
className: targetClass,
|
| 133 |
+
type: 'WORKDAY',
|
| 134 |
+
startDate: date,
|
| 135 |
+
endDate: date,
|
| 136 |
+
name: '补班/工作日'
|
| 137 |
+
});
|
| 138 |
+
} else {
|
| 139 |
+
// Current is Workday -> Set to Off
|
| 140 |
+
if (!confirm(`确定将 ${date} 标记为“休息日”并免除考勤吗?`)) return;
|
| 141 |
await api.calendar.add({
|
| 142 |
schoolId: currentUser?.schoolId!,
|
| 143 |
className: targetClass,
|
| 144 |
type: 'OFF',
|
| 145 |
startDate: date,
|
| 146 |
endDate: date,
|
| 147 |
+
name: '休息/停课'
|
| 148 |
});
|
|
|
|
| 149 |
}
|
| 150 |
+
loadData();
|
| 151 |
};
|
| 152 |
|
| 153 |
if (!targetClass) return <div className="p-10 text-center text-gray-500">您不是班主任,无法管理考勤。</div>;
|
|
|
|
| 162 |
|
| 163 |
return (
|
| 164 |
<div className="space-y-6">
|
|
|
|
| 165 |
<div className="flex flex-col md:flex-row justify-between items-center bg-white p-4 rounded-xl shadow-sm border border-gray-100 gap-4">
|
| 166 |
<div className="flex items-center gap-4 w-full md:w-auto">
|
| 167 |
<h2 className="text-xl font-bold text-gray-800">考勤管理</h2>
|
|
|
|
| 177 |
|
| 178 |
<div className="flex gap-2 items-center w-full md:w-auto">
|
| 179 |
<input type="date" className="border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500" value={date} onChange={e => setDate(e.target.value)} max={new Date().toISOString().split('T')[0]}/>
|
| 180 |
+
|
| 181 |
+
{/* Dynamic Toggle Button */}
|
| 182 |
+
<button
|
| 183 |
+
onClick={toggleDayType}
|
| 184 |
+
className={`px-3 py-2 rounded-lg text-sm whitespace-nowrap flex items-center ${isHoliday ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-orange-100 text-orange-700 hover:bg-orange-200'}`}
|
| 185 |
+
title={isHoliday ? "设为工作日 (开启考勤)" : "设为休息日 (免打卡)"}
|
| 186 |
+
>
|
| 187 |
+
{isHoliday ? <Briefcase size={18}/> : <CalendarOff size={18}/>}
|
| 188 |
+
</button>
|
| 189 |
+
|
| 190 |
+
{!isHoliday && (
|
| 191 |
+
<button onClick={handleBatchCheckIn} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors shadow-sm whitespace-nowrap">
|
| 192 |
+
一键全勤
|
| 193 |
+
</button>
|
| 194 |
)}
|
| 195 |
+
|
| 196 |
<button onClick={() => setIsSettingsOpen(true)} className="bg-gray-100 text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-200 text-sm whitespace-nowrap">
|
| 197 |
<Settings size={18}/>
|
| 198 |
</button>
|
| 199 |
</div>
|
| 200 |
</div>
|
| 201 |
|
|
|
|
| 202 |
{!isHoliday && (
|
| 203 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in slide-in-from-top-2">
|
| 204 |
+
<div className="bg-green-50 p-4 rounded-lg border border-green-100 flex flex-col items-center"><span className="text-green-600 font-bold text-2xl">{stats.present}</span><span className="text-xs text-green-700">出勤</span></div>
|
| 205 |
+
<div className="bg-orange-50 p-4 rounded-lg border border-orange-100 flex flex-col items-center"><span className="text-orange-500 font-bold text-2xl">{stats.leave}</span><span className="text-xs text-orange-700">请假</span></div>
|
| 206 |
+
<div className="bg-red-50 p-4 rounded-lg border border-red-100 flex flex-col items-center"><span className="text-red-500 font-bold text-2xl">{stats.absent}</span><span className="text-xs text-red-700">缺勤</span></div>
|
| 207 |
+
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200 flex flex-col items-center"><span className="text-gray-500 font-bold text-2xl">{missing}</span><span className="text-xs text-gray-600">未记录</span></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
</div>
|
| 209 |
)}
|
| 210 |
|
|
|
|
| 213 |
{students.map(s => {
|
| 214 |
const record = attendanceMap[s._id || String(s.id)];
|
| 215 |
const status = record?.status || 'None';
|
|
|
|
| 216 |
let bg = 'bg-white border-gray-200';
|
| 217 |
let icon = <Clock size={20} className="text-gray-300"/>;
|
| 218 |
let text = '未打卡';
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
return (
|
| 244 |
+
<div key={s._id} onClick={() => toggleStatus(s._id || String(s.id))} className={`p-4 rounded-xl border cursor-pointer transition-all hover:shadow-md flex flex-col items-center justify-center space-y-2 select-none relative group ${bg} ${isHoliday ? 'cursor-default' : ''}`}>
|
| 245 |
+
<div className={`w-12 h-12 rounded-full flex items-center justify-center text-lg font-bold text-white ${s.gender==='Female'?'bg-pink-400':'bg-blue-400'} ${isHoliday ? 'grayscale opacity-50' : ''}`}>{s.name[0]}</div>
|
| 246 |
+
<div className="text-center"><p className="font-bold text-gray-800 text-sm">{s.name}</p><p className="text-[10px] text-gray-400">{s.seatNo ? `${s.seatNo}号` : s.studentNo}</p></div>
|
| 247 |
+
<div className={`flex items-center gap-1 text-xs font-bold ${textColor}`}>{icon} {text}</div>
|
| 248 |
+
{!isHoliday && <div className="absolute inset-0 bg-black/5 opacity-0 group-hover:opacity-100 rounded-xl transition-opacity flex items-center justify-center text-xs text-gray-600 font-bold">点击切换</div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
</div>
|
| 250 |
);
|
| 251 |
})}
|
| 252 |
</div>
|
| 253 |
)}
|
| 254 |
|
|
|
|
| 255 |
{isSettingsOpen && (
|
| 256 |
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 257 |
<div className="bg-white rounded-xl w-full max-w-lg p-6 shadow-2xl animate-in zoom-in-95">
|
| 258 |
<h3 className="font-bold text-lg mb-4 flex items-center"><Calendar className="mr-2"/> 考勤日历设置</h3>
|
|
|
|
| 259 |
<div className="space-y-6">
|
| 260 |
+
<div className="bg-blue-50 p-4 rounded-lg flex justify-between items-center"><div><p className="font-bold text-blue-800 text-sm">周末自动免打卡</p><p className="text-xs text-blue-600">开启后,周六和周日将自动标记为休息日</p></div><input type="checkbox" checked={excludeWeekends} onChange={e => setExcludeWeekends(e.target.checked)} className="w-5 h-5"/></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
<div>
|
| 262 |
+
<label className="text-sm font-bold text-gray-700 mb-2 block">自定义日程调整 (假期/调休)</label>
|
| 263 |
+
<div className="flex gap-2 mb-3"><input className="border rounded p-2 text-xs flex-1" placeholder="名称 (如: 国庆)" value={newHoliday.name} onChange={e=>setNewHoliday({...newHoliday, name:e.target.value})}/><input type="date" className="border rounded p-2 text-xs" value={newHoliday.startDate} onChange={e=>setNewHoliday({...newHoliday, startDate:e.target.value})}/><span className="self-center text-gray-400">-</span><input type="date" className="border rounded p-2 text-xs" value={newHoliday.endDate} onChange={e=>setNewHoliday({...newHoliday, endDate:e.target.value})}/><button onClick={handleAddHoliday} className="bg-green-600 text-white p-2 rounded hover:bg-green-700"><Plus size={16}/></button></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
<div className="max-h-40 overflow-y-auto border rounded bg-gray-50 custom-scrollbar">
|
| 265 |
+
{calendarEntries.length > 0 ? calendarEntries.map(c => (<div key={c._id} className="flex justify-between items-center p-2 border-b last:border-0 hover:bg-white text-sm"><div><span className={`font-bold mr-2 ${c.type==='WORKDAY'?'text-blue-600':'text-gray-800'}`}>{c.type==='WORKDAY' ? '[班]' : c.type==='OFF' ? '[休]' : ''} {c.name}</span><span className="text-xs text-gray-500">{c.startDate} ~ {c.endDate}</span></div><button onClick={() => handleDeleteHoliday(c._id!)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>)) : <p className="text-center text-gray-400 text-xs py-4">暂无自定义设置</p>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
</div>
|
| 267 |
</div>
|
| 268 |
</div>
|
|
|
|
| 269 |
<button onClick={() => setIsSettingsOpen(false)} className="mt-6 w-full py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 font-bold">关闭</button>
|
| 270 |
</div>
|
| 271 |
</div>
|
pages/Dashboard.tsx
CHANGED
|
@@ -5,6 +5,8 @@ import { api } from '../services/api';
|
|
| 5 |
import { Score, ClassInfo, Subject, Schedule, User } from '../types';
|
| 6 |
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
| 7 |
import { StudentDashboard } from './StudentDashboard';
|
|
|
|
|
|
|
| 8 |
|
| 9 |
interface DashboardProps {
|
| 10 |
onNavigate: (view: string) => void;
|
|
@@ -18,34 +20,20 @@ export const gradeOrder: Record<string, number> = {
|
|
| 18 |
|
| 19 |
export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
|
| 20 |
|
| 21 |
-
// Helper to extract grade from class string (e.g. "一年级(1)班" -> "一年级")
|
| 22 |
const extractGrade = (s: string) => {
|
| 23 |
if (!s) return '';
|
| 24 |
const keys = Object.keys(gradeOrder);
|
| 25 |
return keys.find(g => s.startsWith(g)) || '';
|
| 26 |
};
|
| 27 |
|
| 28 |
-
// Robust sort function
|
| 29 |
export const sortClasses = (a: ClassInfo | string, b: ClassInfo | string) => {
|
| 30 |
-
// Handle both ClassInfo objects and string names
|
| 31 |
const nameA = typeof a === 'string' ? a : (a.grade + a.className);
|
| 32 |
const nameB = typeof b === 'string' ? b : (b.grade + b.className);
|
| 33 |
-
|
| 34 |
if (!nameA || !nameB) return 0;
|
| 35 |
-
|
| 36 |
-
// 1. Sort by Grade First
|
| 37 |
const gradeA = extractGrade(nameA);
|
| 38 |
const gradeB = extractGrade(nameB);
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
return sortGrades(gradeA, gradeB);
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
// 2. Sort by Class Number
|
| 45 |
-
const getNum = (str: string) => {
|
| 46 |
-
const match = str.match(/(\d+)/);
|
| 47 |
-
return match ? parseInt(match[1]) : 0;
|
| 48 |
-
};
|
| 49 |
return getNum(nameA) - getNum(nameB);
|
| 50 |
};
|
| 51 |
|
|
@@ -54,394 +42,126 @@ export const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
|
|
| 54 |
const [warnings, setWarnings] = useState<string[]>([]);
|
| 55 |
const [loading, setLoading] = useState(true);
|
| 56 |
|
| 57 |
-
// Timetable Data
|
| 58 |
-
const [showSchedule, setShowSchedule] = useState(false);
|
| 59 |
-
const [showStatus, setShowStatus] = useState(false);
|
| 60 |
-
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
| 61 |
-
const [classList, setClassList] = useState<ClassInfo[]>([]);
|
| 62 |
-
const [subjects, setSubjects] = useState<Subject[]>([]);
|
| 63 |
-
const [teachers, setTeachers] = useState<User[]>([]);
|
| 64 |
-
|
| 65 |
-
const [viewGrade, setViewGrade] = useState('');
|
| 66 |
-
const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
|
| 67 |
-
const [editForm, setEditForm] = useState({ className: '', subject: '', teacherName: '' });
|
| 68 |
-
|
| 69 |
const [trendData, setTrendData] = useState<any[]>([]);
|
| 70 |
|
| 71 |
const currentUser = api.auth.getCurrentUser();
|
| 72 |
-
const isAdmin = currentUser?.role === 'ADMIN';
|
| 73 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 74 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 75 |
|
| 76 |
-
// If Student, Render Student Dashboard
|
| 77 |
-
if (isStudent) {
|
| 78 |
-
return <StudentDashboard />;
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
// --- Admin/Teacher Logic Below ---
|
| 82 |
-
|
| 83 |
useEffect(() => {
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
| 86 |
try {
|
| 87 |
-
const
|
| 88 |
-
|
| 89 |
-
api.scores.getAll(),
|
| 90 |
-
api.classes.getAll(),
|
| 91 |
-
api.subjects.getAll(),
|
| 92 |
-
api.users.getAll({ role: 'TEACHER' })
|
| 93 |
-
]);
|
| 94 |
-
setStats(summary);
|
| 95 |
|
| 96 |
-
//
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
if (grades.length > 0) {
|
| 106 |
-
setViewGrade(grades[0] as string);
|
| 107 |
-
}
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
const newWarnings: string[] = [];
|
| 111 |
-
subs.forEach((sub: Subject) => {
|
| 112 |
-
const subScores = (scores as Score[]).filter((s: Score) => s.courseName === sub.name && s.status === 'Normal');
|
| 113 |
-
if (subScores.length > 0) {
|
| 114 |
-
const avg = subScores.reduce((a: number, b: Score) => a + b.score, 0) / subScores.length;
|
| 115 |
-
if (avg < 60) newWarnings.push(`全校 ${sub.name} 学科平均分过低 (${avg.toFixed(1)}分)`);
|
| 116 |
-
}
|
| 117 |
-
});
|
| 118 |
-
setWarnings(newWarnings);
|
| 119 |
-
|
| 120 |
-
const examGroups: Record<string, number[]> = {};
|
| 121 |
-
(scores as Score[]).filter((s: Score) => s.status==='Normal').forEach((s: Score) => {
|
| 122 |
-
const key = s.examName || s.type;
|
| 123 |
-
if(!examGroups[key]) examGroups[key] = [];
|
| 124 |
-
examGroups[key].push(s.score);
|
| 125 |
-
});
|
| 126 |
-
const chartData = Object.keys(examGroups).map(k => ({
|
| 127 |
-
name: k,
|
| 128 |
-
avg: Math.round(examGroups[k].reduce((a,b)=>a+b,0)/examGroups[k].length)
|
| 129 |
-
})).slice(0, 6);
|
| 130 |
-
setTrendData(chartData);
|
| 131 |
-
|
| 132 |
-
} catch (error) { console.error(error); }
|
| 133 |
-
finally { setLoading(false); }
|
| 134 |
};
|
| 135 |
-
|
| 136 |
-
}, []);
|
| 137 |
-
|
| 138 |
-
useEffect(() => {
|
| 139 |
-
// Fetch schedules whenever showSchedule opens OR viewGrade changes
|
| 140 |
-
if (showSchedule || viewGrade) fetchSchedules();
|
| 141 |
-
}, [showSchedule, viewGrade]);
|
| 142 |
-
|
| 143 |
-
const fetchSchedules = async () => {
|
| 144 |
-
try {
|
| 145 |
-
const params: any = {};
|
| 146 |
-
if (isAdmin) {
|
| 147 |
-
if (!viewGrade) return;
|
| 148 |
-
// Sending grade parameter which backend will now treat as a regex filter for className
|
| 149 |
-
params.grade = viewGrade;
|
| 150 |
-
} else {
|
| 151 |
-
if (currentUser?.role === 'TEACHER') {
|
| 152 |
-
params.teacherName = currentUser.trueName || currentUser.username;
|
| 153 |
-
}
|
| 154 |
-
}
|
| 155 |
-
const data = await api.schedules.get(params);
|
| 156 |
-
setSchedules(data);
|
| 157 |
-
} catch(e) { console.error(e); }
|
| 158 |
-
};
|
| 159 |
-
|
| 160 |
-
const handleOpenAddModal = (day: number, period: number) => {
|
| 161 |
-
setEditingCell({ day, period });
|
| 162 |
-
setEditForm({
|
| 163 |
-
className: '',
|
| 164 |
-
subject: isTeacher ? (currentUser?.teachingSubject || '') : '',
|
| 165 |
-
teacherName: isTeacher ? (currentUser?.trueName || currentUser?.username || '') : ''
|
| 166 |
-
});
|
| 167 |
-
};
|
| 168 |
-
|
| 169 |
-
const handleSaveSchedule = async () => {
|
| 170 |
-
if (!editingCell) return;
|
| 171 |
-
if (!editForm.className) return alert('请选择班级');
|
| 172 |
-
if (!editForm.subject) return alert('请选择科目');
|
| 173 |
-
if (!editForm.teacherName) return alert('请选择任课教师');
|
| 174 |
-
|
| 175 |
-
try {
|
| 176 |
-
await api.schedules.save({
|
| 177 |
-
className: editForm.className,
|
| 178 |
-
dayOfWeek: editingCell.day,
|
| 179 |
-
period: editingCell.period,
|
| 180 |
-
subject: editForm.subject,
|
| 181 |
-
teacherName: editForm.teacherName
|
| 182 |
-
});
|
| 183 |
-
setEditingCell(null);
|
| 184 |
-
fetchSchedules();
|
| 185 |
-
} catch (err: any) {
|
| 186 |
-
alert('排课失败:' + (err.message || '未知错误'));
|
| 187 |
-
}
|
| 188 |
-
};
|
| 189 |
-
|
| 190 |
-
const handleDeleteSchedule = async (schedule: Schedule) => {
|
| 191 |
-
if (!confirm(`确定删除 ${schedule.className} 的 ${schedule.subject} 课?`)) return;
|
| 192 |
-
await api.schedules.delete({
|
| 193 |
-
className: schedule.className,
|
| 194 |
-
dayOfWeek: schedule.dayOfWeek,
|
| 195 |
-
period: schedule.period
|
| 196 |
-
});
|
| 197 |
-
fetchSchedules();
|
| 198 |
-
};
|
| 199 |
-
|
| 200 |
-
const uniqueGrades = Array.from(new Set(classList.map(c => c.grade))).sort(sortGrades);
|
| 201 |
-
|
| 202 |
-
// Robust class mapping for modal
|
| 203 |
-
const modalClassOptions = classList
|
| 204 |
-
.filter(c => !isAdmin || !viewGrade || c.grade === viewGrade)
|
| 205 |
-
.map(c => ({
|
| 206 |
-
id: c._id,
|
| 207 |
-
name: (c.className.startsWith(c.grade) ? c.className : c.grade + c.className)
|
| 208 |
-
}))
|
| 209 |
-
.sort((a,b) => sortClasses(a.name, b.name))
|
| 210 |
-
.map(c => c.name);
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
{ label: '开设课程', value: stats.courseCount, icon: BookOpen, color: 'bg-emerald-500', trend: '实时' },
|
| 215 |
-
{ label: '平均成绩', value: stats.avgScore, icon: GraduationCap, color: 'bg-violet-500', trend: '全校' },
|
| 216 |
-
{ label: '优秀率', value: stats.excellentRate, icon: TrendingUp, color: 'bg-orange-500', trend: '>=90分' },
|
| 217 |
-
];
|
| 218 |
|
|
|
|
| 219 |
return (
|
| 220 |
<div className="space-y-6">
|
| 221 |
-
<
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
</div>
|
| 226 |
-
<div className="
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
| 233 |
</div>
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
{cards.map((card, index) => (
|
| 238 |
-
<div key={index} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex items-start justify-between hover:shadow-md transition-shadow relative overflow-hidden group">
|
| 239 |
-
<div className="relative z-10">
|
| 240 |
-
<p className="text-sm font-medium text-gray-500 mb-1">{card.label}</p>
|
| 241 |
-
<h3 className="text-3xl font-bold text-gray-800">{card.value}</h3>
|
| 242 |
-
<div className={`flex items-center mt-2 text-xs font-medium text-gray-400`}>
|
| 243 |
-
<span className={`px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 mr-2`}>{card.trend}</span>
|
| 244 |
-
统计数据
|
| 245 |
-
</div>
|
| 246 |
</div>
|
| 247 |
-
<div
|
| 248 |
-
|
|
|
|
| 249 |
</div>
|
| 250 |
-
</div>
|
| 251 |
-
))}
|
| 252 |
-
</div>
|
| 253 |
-
|
| 254 |
-
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 255 |
-
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 256 |
-
<div className="flex items-center justify-between mb-6">
|
| 257 |
-
<h3 className="text-lg font-bold text-gray-800">全校考试成绩走势</h3>
|
| 258 |
-
<button onClick={() => onNavigate('reports')} className="text-sm text-blue-600 hover:text-blue-700 font-medium">查看详情 →</button>
|
| 259 |
-
</div>
|
| 260 |
-
<div className="h-64">
|
| 261 |
-
{trendData.length > 0 ? (
|
| 262 |
-
<ResponsiveContainer width="100%" height="100%">
|
| 263 |
-
<LineChart data={trendData}>
|
| 264 |
-
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0"/>
|
| 265 |
-
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize:12}}/>
|
| 266 |
-
<YAxis axisLine={false} tickLine={false} domain={[0, 100]}/>
|
| 267 |
-
<Tooltip contentStyle={{borderRadius: '8px', border:'none', boxShadow:'0 4px 6px -1px rgba(0,0,0,0.1)'}}/>
|
| 268 |
-
<Line type="monotone" dataKey="avg" stroke="#3b82f6" strokeWidth={3} dot={{r:4}} name="平均分"/>
|
| 269 |
-
</LineChart>
|
| 270 |
-
</ResponsiveContainer>
|
| 271 |
-
) : <div className="h-full flex items-center justify-center text-gray-400">暂无考试数据</div>}
|
| 272 |
-
</div>
|
| 273 |
</div>
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
{warnings.length > 0 ? warnings.map((w, i) => (
|
| 283 |
-
<div key={i} className="text-xs text-gray-600 bg-amber-50 p-2 rounded border border-amber-100">{w}</div>
|
| 284 |
-
)) : <div className="text-center text-xs text-gray-400">运行平稳</div>}
|
| 285 |
-
</div>
|
| 286 |
-
</div>
|
| 287 |
-
|
| 288 |
-
<div className="bg-gradient-to-br from-indigo-600 to-blue-700 rounded-xl shadow-lg p-5 text-white">
|
| 289 |
-
<h3 className="font-bold mb-4">快捷操作</h3>
|
| 290 |
-
<div className="grid grid-cols-2 gap-3">
|
| 291 |
-
<button onClick={() => onNavigate('grades')} className="bg-white/10 hover:bg-white/20 p-2 rounded flex flex-col items-center text-xs transition-colors">
|
| 292 |
-
<Plus size={20} className="mb-1"/> 录入成绩
|
| 293 |
-
</button>
|
| 294 |
-
<button onClick={() => onNavigate('students')} className="bg-white/10 hover:bg-white/20 p-2 rounded flex flex-col items-center text-xs transition-colors">
|
| 295 |
-
<Users size={20} className="mb-1"/> 新增学生
|
| 296 |
-
</button>
|
| 297 |
-
</div>
|
| 298 |
-
</div>
|
| 299 |
</div>
|
| 300 |
</div>
|
| 301 |
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
<
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
</select>
|
| 318 |
-
</div>
|
| 319 |
-
)}
|
| 320 |
-
|
| 321 |
-
{!isAdmin && isTeacher && (
|
| 322 |
-
<div className="text-sm text-gray-500">
|
| 323 |
-
查看: <span className="font-bold text-gray-800">{currentUser.trueName || currentUser.username}</span> 的课表
|
| 324 |
-
</div>
|
| 325 |
-
)}
|
| 326 |
-
</div>
|
| 327 |
-
<button onClick={()=>setShowSchedule(false)} className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full p-2 transition-all"><X size={24}/></button>
|
| 328 |
-
</div>
|
| 329 |
-
|
| 330 |
-
<div className="flex-1 overflow-auto border rounded-xl shadow-inner bg-gray-50/30">
|
| 331 |
-
<table className="w-full border-collapse text-center table-fixed min-w-[800px]">
|
| 332 |
-
<thead className="sticky top-0 bg-white z-10 shadow-sm">
|
| 333 |
-
<tr className="text-gray-500 uppercase text-sm">
|
| 334 |
-
<th className="p-4 border-b border-r w-20 bg-gray-50">节次</th>
|
| 335 |
-
{['周一','周二','周三','周四','周五'].map(d=><th key={d} className="p-4 border-b font-bold text-gray-700">{d}</th>)}
|
| 336 |
-
</tr>
|
| 337 |
-
</thead>
|
| 338 |
-
<tbody className="text-sm bg-white">
|
| 339 |
-
{[1,2,3,4,5,6,7,8].map(period => (
|
| 340 |
-
<tr key={period} className="divide-x divide-gray-100">
|
| 341 |
-
<td className="p-4 border-b border-r font-bold text-gray-400 bg-gray-50/50">第{period}节</td>
|
| 342 |
-
{[1,2,3,4,5].map(day => {
|
| 343 |
-
const slotItems = schedules.filter(s => s.dayOfWeek === day && s.period === period);
|
| 344 |
-
const canAdd = isAdmin || isTeacher;
|
| 345 |
-
|
| 346 |
-
return (
|
| 347 |
-
<td key={day} className="p-2 border-b h-28 align-top transition-colors relative group">
|
| 348 |
-
<div className="flex flex-wrap gap-2 content-start h-full overflow-y-auto custom-scrollbar pb-6">
|
| 349 |
-
{slotItems.map(item => {
|
| 350 |
-
const canDelete = isAdmin || (isTeacher && (item.teacherName === currentUser.trueName || item.teacherName === currentUser.username));
|
| 351 |
-
return (
|
| 352 |
-
<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">
|
| 353 |
-
<div className="font-bold text-blue-700 flex justify-between items-center mb-1">
|
| 354 |
-
<span className="truncate mr-1">{item.subject}</span>
|
| 355 |
-
{canDelete && (
|
| 356 |
-
<button
|
| 357 |
-
onClick={(e)=>{ e.stopPropagation(); handleDeleteSchedule(item); }}
|
| 358 |
-
className="text-gray-300 hover:text-red-500 transition-colors p-0.5"
|
| 359 |
-
title="删除课程"
|
| 360 |
-
>
|
| 361 |
-
<X size={14}/>
|
| 362 |
-
</button>
|
| 363 |
-
)}
|
| 364 |
-
</div>
|
| 365 |
-
<div className="flex justify-between items-center text-gray-600">
|
| 366 |
-
<span className="bg-blue-50 text-blue-600 px-1.5 rounded font-mono text-[10px] font-bold">
|
| 367 |
-
{item.className.replace(viewGrade, '')}
|
| 368 |
-
</span>
|
| 369 |
-
<span className="text-[10px] truncate max-w-[50%]">{item.teacherName}</span>
|
| 370 |
-
</div>
|
| 371 |
-
</div>
|
| 372 |
-
);
|
| 373 |
-
})}
|
| 374 |
-
{canAdd && (
|
| 375 |
-
<div className="absolute bottom-1 right-1 left-1 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
| 376 |
-
<button onClick={() => handleOpenAddModal(day, period)} 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">
|
| 377 |
-
<Plus size={14}/>
|
| 378 |
-
</button>
|
| 379 |
-
</div>
|
| 380 |
-
)}
|
| 381 |
-
</div>
|
| 382 |
-
</td>
|
| 383 |
-
);
|
| 384 |
-
})}
|
| 385 |
-
</tr>
|
| 386 |
-
))}
|
| 387 |
-
</tbody>
|
| 388 |
-
</table>
|
| 389 |
-
</div>
|
| 390 |
-
</div>
|
| 391 |
-
|
| 392 |
-
{editingCell && (
|
| 393 |
-
<div className="absolute inset-0 bg-black/20 flex items-center justify-center backdrop-blur-[1px] z-50">
|
| 394 |
-
<div className="bg-white p-6 rounded-xl shadow-2xl w-80 animate-in zoom-in-95 border border-gray-100">
|
| 395 |
-
<h4 className="font-bold mb-4 text-gray-800 border-b pb-2">
|
| 396 |
-
{isTeacher ? '我的排课' : '排课管理'} <br/>
|
| 397 |
-
<span className="text-sm font-normal text-gray-500">周{['一','二','三','四','五'][editingCell.day-1]} 第{editingCell.period}节</span>
|
| 398 |
-
</h4>
|
| 399 |
-
<div className="space-y-4">
|
| 400 |
-
<div>
|
| 401 |
-
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">班级</label>
|
| 402 |
-
<select className="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none" value={editForm.className} onChange={e=>setEditForm({...editForm, className: e.target.value})}>
|
| 403 |
-
<option value="">-- 请选择班级 --</option>
|
| 404 |
-
{modalClassOptions.map(c => <option key={c} value={c}>{c}</option>)}
|
| 405 |
-
</select>
|
| 406 |
-
</div>
|
| 407 |
-
<div>
|
| 408 |
-
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">科目</label>
|
| 409 |
-
<select 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" value={editForm.subject} onChange={e=>setEditForm({...editForm, subject: e.target.value})} disabled={isTeacher && !!currentUser?.teachingSubject}>
|
| 410 |
-
<option value="">-- 请选择科目 --</option>
|
| 411 |
-
{subjects.map(s=><option key={s._id} value={s.name}>{s.name}</option>)}
|
| 412 |
-
</select>
|
| 413 |
-
</div>
|
| 414 |
-
<div>
|
| 415 |
-
<label className="text-xs font-bold text-gray-500 uppercase mb-1 block">任课教师</label>
|
| 416 |
-
<select 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" value={editForm.teacherName} onChange={e=>setEditForm({...editForm, teacherName: e.target.value})} disabled={isTeacher}>
|
| 417 |
-
<option value="">-- 请选择教师 --</option>
|
| 418 |
-
{teachers.map(t=><option key={t._id} value={t.trueName || t.username}>{t.trueName} ({t.teachingSubject || '无科目'})</option>)}
|
| 419 |
-
</select>
|
| 420 |
-
</div>
|
| 421 |
-
<div className="flex gap-3 pt-2">
|
| 422 |
-
<button onClick={handleSaveSchedule} className="flex-1 bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">确认添加</button>
|
| 423 |
-
<button onClick={()=>setEditingCell(null)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors">取消</button>
|
| 424 |
-
</div>
|
| 425 |
-
</div>
|
| 426 |
-
</div>
|
| 427 |
-
</div>
|
| 428 |
-
)}
|
| 429 |
-
</div>
|
| 430 |
-
)}
|
| 431 |
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
<
|
| 439 |
-
<
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
</div>
|
| 446 |
);
|
| 447 |
};
|
|
|
|
| 5 |
import { Score, ClassInfo, Subject, Schedule, User } from '../types';
|
| 6 |
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
| 7 |
import { StudentDashboard } from './StudentDashboard';
|
| 8 |
+
import { TeacherDashboard } from './TeacherDashboard';
|
| 9 |
+
import { TodoList } from '../components/TodoList';
|
| 10 |
|
| 11 |
interface DashboardProps {
|
| 12 |
onNavigate: (view: string) => void;
|
|
|
|
| 20 |
|
| 21 |
export const sortGrades = (a: string, b: string) => (gradeOrder[a] || 99) - (gradeOrder[b] || 99);
|
| 22 |
|
|
|
|
| 23 |
const extractGrade = (s: string) => {
|
| 24 |
if (!s) return '';
|
| 25 |
const keys = Object.keys(gradeOrder);
|
| 26 |
return keys.find(g => s.startsWith(g)) || '';
|
| 27 |
};
|
| 28 |
|
|
|
|
| 29 |
export const sortClasses = (a: ClassInfo | string, b: ClassInfo | string) => {
|
|
|
|
| 30 |
const nameA = typeof a === 'string' ? a : (a.grade + a.className);
|
| 31 |
const nameB = typeof b === 'string' ? b : (b.grade + b.className);
|
|
|
|
| 32 |
if (!nameA || !nameB) return 0;
|
|
|
|
|
|
|
| 33 |
const gradeA = extractGrade(nameA);
|
| 34 |
const gradeB = extractGrade(nameB);
|
| 35 |
+
if (gradeA && gradeB && gradeA !== gradeB) { return sortGrades(gradeA, gradeB); }
|
| 36 |
+
const getNum = (str: string) => { const match = str.match(/(\d+)/); return match ? parseInt(match[1]) : 0; };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return getNum(nameA) - getNum(nameB);
|
| 38 |
};
|
| 39 |
|
|
|
|
| 42 |
const [warnings, setWarnings] = useState<string[]>([]);
|
| 43 |
const [loading, setLoading] = useState(true);
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
const [trendData, setTrendData] = useState<any[]>([]);
|
| 46 |
|
| 47 |
const currentUser = api.auth.getCurrentUser();
|
| 48 |
+
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'PRINCIPAL';
|
| 49 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 50 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
useEffect(() => {
|
| 53 |
+
if (isStudent || isTeacher) {
|
| 54 |
+
setLoading(false);
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
const loadStats = async () => {
|
| 58 |
try {
|
| 59 |
+
const data = await api.stats.getSummary();
|
| 60 |
+
setStats(data);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
// Mock trend data for admin view
|
| 63 |
+
setTrendData([
|
| 64 |
+
{ name: '9月', score: 75 },
|
| 65 |
+
{ name: '10月', score: 78 },
|
| 66 |
+
{ name: '11月', score: 82 },
|
| 67 |
+
{ name: '12月', score: 80 },
|
| 68 |
+
{ name: '1月', score: 85 }
|
| 69 |
+
]);
|
| 70 |
|
| 71 |
+
} catch (e) {
|
| 72 |
+
console.error(e);
|
| 73 |
+
} finally {
|
| 74 |
+
setLoading(false);
|
| 75 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
};
|
| 77 |
+
loadStats();
|
| 78 |
+
}, [isStudent, isTeacher]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
if (isStudent) return <StudentDashboard />;
|
| 81 |
+
if (isTeacher) return <TeacherDashboard />;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
+
// Admin Dashboard View
|
| 84 |
return (
|
| 85 |
<div className="space-y-6">
|
| 86 |
+
<h1 className="text-2xl font-bold text-gray-800">全校概览</h1>
|
| 87 |
+
|
| 88 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
| 89 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center">
|
| 90 |
+
<div className="p-3 bg-blue-100 rounded-full text-blue-600 mr-4">
|
| 91 |
+
<Users size={24} />
|
| 92 |
+
</div>
|
| 93 |
+
<div>
|
| 94 |
+
<p className="text-gray-500 text-sm">在校学生</p>
|
| 95 |
+
<p className="text-2xl font-bold text-gray-800">{stats.studentCount}</p>
|
| 96 |
+
</div>
|
| 97 |
</div>
|
| 98 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center">
|
| 99 |
+
<div className="p-3 bg-green-100 rounded-full text-green-600 mr-4">
|
| 100 |
+
<BookOpen size={24} />
|
| 101 |
+
</div>
|
| 102 |
+
<div>
|
| 103 |
+
<p className="text-gray-500 text-sm">开设课程</p>
|
| 104 |
+
<p className="text-2xl font-bold text-gray-800">{stats.courseCount}</p>
|
| 105 |
+
</div>
|
| 106 |
</div>
|
| 107 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center">
|
| 108 |
+
<div className="p-3 bg-yellow-100 rounded-full text-yellow-600 mr-4">
|
| 109 |
+
<GraduationCap size={24} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
</div>
|
| 111 |
+
<div>
|
| 112 |
+
<p className="text-gray-500 text-sm">平均分</p>
|
| 113 |
+
<p className="text-2xl font-bold text-gray-800">{stats.avgScore}</p>
|
| 114 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
</div>
|
| 116 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center">
|
| 117 |
+
<div className="p-3 bg-purple-100 rounded-full text-purple-600 mr-4">
|
| 118 |
+
<Activity size={24} />
|
| 119 |
+
</div>
|
| 120 |
+
<div>
|
| 121 |
+
<p className="text-gray-500 text-sm">优秀率</p>
|
| 122 |
+
<p className="text-2xl font-bold text-gray-800">{stats.excellentRate}</p>
|
| 123 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
</div>
|
| 126 |
|
| 127 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 128 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 129 |
+
<h3 className="font-bold text-gray-800 mb-4 flex items-center"><TrendingUp size={20} className="mr-2 text-blue-600"/> 成绩走势</h3>
|
| 130 |
+
<div className="h-64">
|
| 131 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 132 |
+
<LineChart data={trendData}>
|
| 133 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 134 |
+
<XAxis dataKey="name" />
|
| 135 |
+
<YAxis domain={[0, 100]} />
|
| 136 |
+
<Tooltip />
|
| 137 |
+
<Line type="monotone" dataKey="score" stroke="#3b82f6" strokeWidth={3} dot={{r: 4}} />
|
| 138 |
+
</LineChart>
|
| 139 |
+
</ResponsiveContainer>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
+
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 144 |
+
<h3 className="font-bold text-gray-800 mb-4">快捷入口</h3>
|
| 145 |
+
<div className="grid grid-cols-2 gap-4">
|
| 146 |
+
<button onClick={() => onNavigate('students')} className="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors flex flex-col items-center justify-center text-gray-700">
|
| 147 |
+
<Users className="mb-2 text-blue-500" />
|
| 148 |
+
<span>学生管理</span>
|
| 149 |
+
</button>
|
| 150 |
+
<button onClick={() => onNavigate('classes')} className="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors flex flex-col items-center justify-center text-gray-700">
|
| 151 |
+
<BookOpen className="mb-2 text-green-500" />
|
| 152 |
+
<span>班级管理</span>
|
| 153 |
+
</button>
|
| 154 |
+
<button onClick={() => onNavigate('reports')} className="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors flex flex-col items-center justify-center text-gray-700">
|
| 155 |
+
<Activity className="mb-2 text-purple-500" />
|
| 156 |
+
<span>报表统计</span>
|
| 157 |
+
</button>
|
| 158 |
+
<button onClick={() => onNavigate('settings')} className="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors flex flex-col items-center justify-center text-gray-700">
|
| 159 |
+
<Activity className="mb-2 text-gray-500" />
|
| 160 |
+
<span>系统设置</span>
|
| 161 |
+
</button>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
</div>
|
| 166 |
);
|
| 167 |
};
|
pages/GameMountain.tsx
CHANGED
|
@@ -6,31 +6,13 @@ import { GameSession, GameTeam, Student, GameRewardConfig, AchievementConfig, Cl
|
|
| 6 |
import { Settings, Plus, Minus, Users, CheckSquare, Loader2, Trash2, X, Flag, Gift, Star, Trophy, Maximize, Minimize, Lock } from 'lucide-react';
|
| 7 |
import { Emoji } from '../components/Emoji';
|
| 8 |
|
| 9 |
-
// ... (
|
| 10 |
const styles = `
|
| 11 |
-
@keyframes float-cloud {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
}
|
| 16 |
-
@keyframes drift {
|
| 17 |
-
from { transform: translateX(-100%); }
|
| 18 |
-
to { transform: translateX(100vw); }
|
| 19 |
-
}
|
| 20 |
-
@keyframes bounce-avatar {
|
| 21 |
-
0%, 100% { transform: translateY(0) scale(1); }
|
| 22 |
-
50% { transform: translateY(-10px) scale(1.1); }
|
| 23 |
-
}
|
| 24 |
-
@keyframes wave {
|
| 25 |
-
0% { transform: rotate(0deg); }
|
| 26 |
-
25% { transform: rotate(-10deg); }
|
| 27 |
-
75% { transform: rotate(10deg); }
|
| 28 |
-
100% { transform: rotate(0deg); }
|
| 29 |
-
}
|
| 30 |
-
@keyframes confetti-fall {
|
| 31 |
-
0% { transform: translateY(-10px) rotate(0deg); opacity: 1; }
|
| 32 |
-
100% { transform: translateY(60px) rotate(360deg); opacity: 0; }
|
| 33 |
-
}
|
| 34 |
.animate-drift-slow { animation: drift 60s linear infinite; }
|
| 35 |
.animate-drift-medium { animation: drift 40s linear infinite; }
|
| 36 |
.animate-drift-fast { animation: drift 25s linear infinite; }
|
|
@@ -39,7 +21,7 @@ const styles = `
|
|
| 39 |
.animate-confetti { animation: confetti-fall 2s ease-out forwards; }
|
| 40 |
`;
|
| 41 |
|
| 42 |
-
// ... (Keep calculatePathPoint and generatePathString unchanged)
|
| 43 |
const calculatePathPoint = (progress: number) => {
|
| 44 |
const y = 8 + (progress * 80);
|
| 45 |
const amplitude = 15 * (1 - (progress * 0.3));
|
|
@@ -71,6 +53,7 @@ const CelebrationEffects = () => (
|
|
| 71 |
</div>
|
| 72 |
);
|
| 73 |
|
|
|
|
| 74 |
const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, isFullscreen }: {
|
| 75 |
team: GameTeam,
|
| 76 |
index: number,
|
|
@@ -87,8 +70,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 87 |
|
| 88 |
return (
|
| 89 |
<div className={`relative flex flex-col items-center justify-end h-[95%] ${widthClass} mx-2 flex-shrink-0 select-none group perspective-1000 transition-all duration-300`}>
|
| 90 |
-
|
| 91 |
-
{/* Team Name Cloud Tag */}
|
| 92 |
<div className="absolute top-[2%] z-20 transition-transform duration-300 hover:-translate-y-2 hover:scale-105 cursor-pointer w-full flex justify-center">
|
| 93 |
<div className="relative bg-white/90 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-md border-2 border-white text-center max-w-[90%]">
|
| 94 |
<h3 className="text-sm md:text-base font-black text-slate-800 truncate">{team.name}</h3>
|
|
@@ -98,7 +79,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
|
| 101 |
-
{/* Mountain SVG */}
|
| 102 |
<div className="absolute bottom-0 left-0 w-full h-[90%] z-0 filter drop-shadow-lg transition-all duration-500 group-hover:drop-shadow-xl">
|
| 103 |
<svg viewBox="0 0 200 300" preserveAspectRatio="none" className="w-full h-full overflow-visible">
|
| 104 |
<defs>
|
|
@@ -120,7 +100,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 120 |
</svg>
|
| 121 |
</div>
|
| 122 |
|
| 123 |
-
{/* Rewards */}
|
| 124 |
<div className="absolute bottom-0 left-0 w-full h-[90%] z-10 pointer-events-none">
|
| 125 |
{rewardsConfig.map((reward, i) => {
|
| 126 |
const rPct = reward.scoreThreshold / maxSteps;
|
|
@@ -137,14 +116,13 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 137 |
{isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-sm opacity-50 animate-pulse"></div>}
|
| 138 |
</div>
|
| 139 |
<div className={`mt-1 px-2 py-0.5 rounded text-[10px] font-bold shadow-sm whitespace-nowrap border max-w-[100px] truncate transition-colors ${isUnlocked ? 'bg-yellow-50 text-yellow-700 border-yellow-200' : 'bg-white/90 text-gray-500 border-gray-200'}`}>
|
| 140 |
-
{reward.rewardName}
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
);
|
| 144 |
})}
|
| 145 |
</div>
|
| 146 |
|
| 147 |
-
{/* Climber */}
|
| 148 |
<div className="absolute z-30 transition-all duration-700 ease-in-out flex flex-col items-center -translate-x-1/2 transform translate-y-1/2" style={{ bottom: `${currentPos.y}%`, left: `${currentPos.x}%` }}>
|
| 149 |
<div className={`w-12 h-12 md:w-14 md:h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transition-transform relative ${isFinished ? 'animate-bounce border-yellow-400' : 'hover:scale-110'}`} style={{ borderColor: isFinished ? '#facc15' : team.color }}>
|
| 150 |
<span className="text-2xl md:text-3xl"><Emoji symbol={team.avatar || '🧗'} /></span>
|
|
@@ -153,7 +131,6 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 153 |
</div>
|
| 154 |
</div>
|
| 155 |
|
| 156 |
-
{/* Control Panel */}
|
| 157 |
{onScoreChange && (
|
| 158 |
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-all z-40 bg-white/90 backdrop-blur px-2 py-1 rounded-full shadow-lg border border-gray-200 hover:scale-105">
|
| 159 |
<button onClick={() => onScoreChange(team.id, -1)} className="p-1 rounded-full bg-slate-100 text-slate-500 hover:bg-red-100 hover:text-red-600"><Minus size={14}/></button>
|
|
@@ -164,7 +141,7 @@ const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, is
|
|
| 164 |
);
|
| 165 |
};
|
| 166 |
|
| 167 |
-
//
|
| 168 |
const GameToast = ({ title, message, type, onClose }: { title: string, message: string, type: 'success' | 'info', onClose: () => void }) => {
|
| 169 |
useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]);
|
| 170 |
return (
|
|
@@ -178,37 +155,29 @@ const GameToast = ({ title, message, type, onClose }: { title: string, message:
|
|
| 178 |
};
|
| 179 |
|
| 180 |
export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
|
|
| 181 |
const [session, setSession] = useState<GameSession | null>(null);
|
| 182 |
const [loading, setLoading] = useState(true);
|
| 183 |
const [students, setStudents] = useState<Student[]>([]);
|
| 184 |
-
|
| 185 |
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 186 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 187 |
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
| 188 |
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
| 189 |
-
const [canEdit, setCanEdit] = useState(false);
|
| 190 |
-
|
| 191 |
const [toast, setToast] = useState<{title: string, message: string, type: 'success' | 'info'} | null>(null);
|
| 192 |
|
| 193 |
const currentUser = api.auth.getCurrentUser();
|
| 194 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 195 |
const isAdmin = currentUser?.role === 'ADMIN';
|
| 196 |
-
|
| 197 |
-
// Use prop className or fallback to homeroom (for legacy)
|
| 198 |
const targetClass = className || currentUser?.homeroomClass || (currentUser?.role === 'STUDENT' ? 'MY_CLASS' : '');
|
| 199 |
|
| 200 |
useEffect(() => { loadData(); }, [targetClass]);
|
| 201 |
|
| 202 |
const loadData = async () => {
|
| 203 |
-
// FIX: Check for currentUser
|
| 204 |
if (!targetClass || targetClass === 'MY_CLASS' && !currentUser?.homeroomClass && currentUser?.role !== 'STUDENT') return;
|
| 205 |
setLoading(true);
|
| 206 |
-
|
| 207 |
-
// Resolve "MY_CLASS" for students
|
| 208 |
let resolvedClass = targetClass;
|
| 209 |
if (targetClass === 'MY_CLASS' && currentUser?.role === 'STUDENT') {
|
| 210 |
-
// We need to fetch student's class first? Actually dashboard loads it.
|
| 211 |
-
// Assuming passed prop is correct or we fetch student
|
| 212 |
const stus = await api.students.getAll();
|
| 213 |
const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
|
| 214 |
if(me) resolvedClass = me.className;
|
|
@@ -221,8 +190,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 221 |
]);
|
| 222 |
|
| 223 |
const filteredStudents = allStudents.filter((s: Student) => s.className === resolvedClass);
|
| 224 |
-
// Sort
|
| 225 |
-
// FIX: Add explicit types for sort arguments
|
| 226 |
filteredStudents.sort((a: Student, b: Student) => {
|
| 227 |
const seatA = parseInt(a.seatNo || '99999');
|
| 228 |
const seatB = parseInt(b.seatNo || '99999');
|
|
@@ -234,7 +201,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 234 |
if (sess) {
|
| 235 |
setSession(sess);
|
| 236 |
} else if (isTeacher && currentUser?.schoolId) {
|
| 237 |
-
// Initialize if empty
|
| 238 |
const newSess: GameSession = {
|
| 239 |
schoolId: currentUser.schoolId,
|
| 240 |
className: resolvedClass,
|
|
@@ -247,31 +213,23 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 247 |
{ id: '3', name: '飞龙队', score: 0, avatar: '🐉', color: '#10b981', members: [] }
|
| 248 |
],
|
| 249 |
rewardsConfig: [
|
| 250 |
-
{ scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券
|
| 251 |
{ scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大礼', rewardValue: 1 }
|
| 252 |
]
|
| 253 |
};
|
| 254 |
setSession(newSess);
|
| 255 |
}
|
| 256 |
|
| 257 |
-
// Permissions Check
|
| 258 |
if (isAdmin) setCanEdit(true);
|
| 259 |
else if (isTeacher && currentUser) {
|
| 260 |
-
// Fetch class info to check homeroomTeacherIds
|
| 261 |
const clsList = await api.classes.getAll() as ClassInfo[];
|
| 262 |
-
// FIX: Add type for find callback parameter
|
| 263 |
const cls = clsList.find((c: ClassInfo) => c.grade + c.className === resolvedClass);
|
| 264 |
if (cls && (cls.homeroomTeacherIds?.includes(currentUser._id || '') || cls.teacherName?.includes(currentUser.trueName || currentUser.username))) {
|
| 265 |
setCanEdit(true);
|
| 266 |
-
// Only load Ach config if can edit
|
| 267 |
const ac = await api.achievements.getConfig(resolvedClass);
|
| 268 |
setAchConfig(ac);
|
| 269 |
-
} else {
|
| 270 |
-
|
| 271 |
-
}
|
| 272 |
-
} else {
|
| 273 |
-
setCanEdit(false);
|
| 274 |
-
}
|
| 275 |
|
| 276 |
} catch (e) { console.error(e); }
|
| 277 |
finally { setLoading(false); }
|
|
@@ -291,7 +249,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 291 |
if (newScore === session.maxSteps) {
|
| 292 |
setToast({ title: `🏆 巅峰时刻!`, message: `恭喜 [${t.name}] 成功登顶!`, type: 'success' });
|
| 293 |
} else if (reward) {
|
| 294 |
-
setToast({ title: `🎉 触发奖励!`, message: `[${t.name}] 获得:${reward.rewardName}`, type: 'info' });
|
| 295 |
}
|
| 296 |
|
| 297 |
if (reward) {
|
|
@@ -311,6 +269,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 311 |
studentName: stu.name,
|
| 312 |
rewardType: reward.rewardType as any,
|
| 313 |
name: reward.rewardName,
|
|
|
|
| 314 |
status: 'PENDING',
|
| 315 |
source: `群岳争锋 - ${t.name} ${newScore}步`
|
| 316 |
});
|
|
@@ -329,7 +288,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 329 |
await api.games.saveMountainSession(newSession);
|
| 330 |
} catch(e) {
|
| 331 |
alert('保存失败:您可能没有权限修改此班级数据');
|
| 332 |
-
loadData();
|
| 333 |
}
|
| 334 |
};
|
| 335 |
|
|
@@ -363,51 +322,29 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 363 |
const GameContent = (
|
| 364 |
<div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-gradient-to-b from-sky-300 via-sky-100 to-emerald-50 overflow-hidden`}>
|
| 365 |
<style>{styles}</style>
|
| 366 |
-
|
| 367 |
-
{/* Background Elements */}
|
| 368 |
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
| 369 |
<div className="absolute top-10 right-20 w-24 h-24 bg-yellow-300 rounded-full blur-xl opacity-60 animate-pulse"></div>
|
| 370 |
<div className="absolute top-16 -left-20 text-white/60 text-9xl select-none animate-drift-slow opacity-80" style={{filter: 'blur(2px)'}}><Emoji symbol="☁️"/></div>
|
| 371 |
<div className="absolute top-32 -left-40 text-white/40 text-8xl select-none animate-drift-medium opacity-60" style={{animationDelay: '5s'}}><Emoji symbol="☁️"/></div>
|
| 372 |
</div>
|
| 373 |
-
|
| 374 |
-
{/* Toast Overlay */}
|
| 375 |
{toast && <GameToast title={toast.title} message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
| 376 |
-
|
| 377 |
{/* Toolbar */}
|
| 378 |
<div className="absolute top-4 right-4 z-50 flex gap-2">
|
| 379 |
-
{!canEdit && isTeacher && (
|
| 380 |
-
<div className="px-3 py-1.5 bg-gray-100/80 backdrop-blur rounded-full text-xs font-bold text-gray-500 flex items-center border border-gray-200">
|
| 381 |
-
<Lock size={14} className="mr-1"/> 只读模式 (非班主任)
|
| 382 |
-
</div>
|
| 383 |
-
)}
|
| 384 |
<button onClick={() => setIsFullscreen(!isFullscreen)} className="p-2 bg-white/80 backdrop-blur rounded-full hover:bg-white shadow-sm border border-white/50 transition-colors">
|
| 385 |
{isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
|
| 386 |
</button>
|
| 387 |
-
{canEdit && (
|
| 388 |
-
<button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-700 bg-white/90 backdrop-blur px-4 py-2 rounded-2xl border border-white/50 hover:bg-white shadow-md transition-all hover:scale-105 active:scale-95">
|
| 389 |
-
<Settings size={16} className="mr-2"/> 设置
|
| 390 |
-
</button>
|
| 391 |
-
)}
|
| 392 |
</div>
|
| 393 |
-
|
| 394 |
-
{/* Scrollable Game Area */}
|
| 395 |
<div className="flex-1 overflow-x-auto overflow-y-hidden relative custom-scrollbar z-10 w-full min-w-0">
|
| 396 |
<div className="h-full flex items-end px-10 pb-4 gap-2 mx-auto w-max min-w-full justify-center">
|
| 397 |
{session.teams.map((team, idx) => (
|
| 398 |
-
<MountainStage
|
| 399 |
-
key={team.id}
|
| 400 |
-
team={team}
|
| 401 |
-
index={idx}
|
| 402 |
-
rewardsConfig={session.rewardsConfig}
|
| 403 |
-
maxSteps={session.maxSteps}
|
| 404 |
-
onScoreChange={canEdit ? handleScoreChange : undefined}
|
| 405 |
-
isFullscreen={isFullscreen}
|
| 406 |
-
/>
|
| 407 |
))}
|
| 408 |
</div>
|
| 409 |
</div>
|
| 410 |
-
|
| 411 |
{/* SETTINGS MODAL */}
|
| 412 |
{isSettingsOpen && canEdit && (
|
| 413 |
<div className="fixed inset-0 bg-black/60 z-[100000] flex items-center justify-center p-4 backdrop-blur-sm">
|
|
@@ -416,7 +353,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 416 |
<h3 className="text-xl font-bold text-gray-800 flex items-center"><Settings className="mr-2 text-blue-600"/> 游戏控制台</h3>
|
| 417 |
<button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 418 |
</div>
|
| 419 |
-
|
| 420 |
<div className="flex-1 overflow-y-auto p-6 space-y-8 bg-gray-50/50">
|
| 421 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 422 |
{/* Basic Config */}
|
|
@@ -429,8 +365,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 429 |
</div>
|
| 430 |
</div>
|
| 431 |
</section>
|
| 432 |
-
|
| 433 |
-
{/* Rewards Config */}
|
| 434 |
<section className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm row-span-2">
|
| 435 |
<h4 className="font-bold text-gray-700 mb-4 flex items-center text-sm uppercase tracking-wide"><Gift size={16} className="mr-2 text-amber-500"/> 奖励节点</h4>
|
| 436 |
<div className="space-y-2 max-h-64 overflow-y-auto pr-1 custom-scrollbar">
|
|
@@ -451,11 +386,17 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 451 |
<option value="ITEM">🎁 实物</option>
|
| 452 |
<option value="ACHIEVEMENT">🏆 奖状/成就</option>
|
| 453 |
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
<button onClick={() => {
|
| 455 |
const newArr = session.rewardsConfig.filter((_, i) => i !== idx); setSession({...session, rewardsConfig: newArr});
|
| 456 |
}} className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={16}/></button>
|
| 457 |
</div>
|
| 458 |
-
|
| 459 |
{rc.rewardType === 'ACHIEVEMENT' ? (
|
| 460 |
<select className="w-full text-xs border border-gray-200 rounded p-1 bg-white" value={rc.achievementId || ''} onChange={e => {
|
| 461 |
const newArr = [...session.rewardsConfig];
|
|
@@ -477,7 +418,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 477 |
</div>
|
| 478 |
<button onClick={() => setSession({...session, rewardsConfig: [...session.rewardsConfig, { scoreThreshold: session.maxSteps, rewardType: 'DRAW_COUNT', rewardName: '奖励', rewardValue: 1 }]})} className="mt-3 w-full py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors">+ 添加节点</button>
|
| 479 |
</section>
|
| 480 |
-
|
| 481 |
{/* Team Management */}
|
| 482 |
<section className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm md:col-span-2">
|
| 483 |
<div className="flex justify-between items-center mb-4">
|
|
@@ -487,9 +427,7 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 487 |
setSession({ ...session, teams: [...session.teams, newTeam] });
|
| 488 |
}} className="text-xs bg-emerald-50 text-emerald-600 px-3 py-1.5 rounded-lg hover:bg-emerald-100 border border-emerald-200 font-bold transition-colors">+ 新建队伍</button>
|
| 489 |
</div>
|
| 490 |
-
|
| 491 |
<div className="flex flex-col md:flex-row gap-6 h-[400px]">
|
| 492 |
-
{/* Team List */}
|
| 493 |
<div className="w-full md:w-1/3 space-y-3 overflow-y-auto pr-2 custom-scrollbar">
|
| 494 |
{session.teams.map(t => (
|
| 495 |
<div key={t.id}
|
|
@@ -521,8 +459,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 521 |
</div>
|
| 522 |
))}
|
| 523 |
</div>
|
| 524 |
-
|
| 525 |
-
{/* Member Shuttle */}
|
| 526 |
<div className="flex-1 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col shadow-inner">
|
| 527 |
{selectedTeamId ? (
|
| 528 |
<>
|
|
@@ -539,7 +475,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 539 |
const isInCurrent = currentTeamId === selectedTeamId;
|
| 540 |
const isInOther = currentTeamId && !isInCurrent;
|
| 541 |
const otherTeam = isInOther ? session.teams.find(t => t.id === currentTeamId) : null;
|
| 542 |
-
|
| 543 |
return (
|
| 544 |
<div key={s._id}
|
| 545 |
onClick={() => toggleTeamMember(s._id || String(s.id), selectedTeamId)}
|
|
@@ -557,18 +492,12 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 557 |
})}
|
| 558 |
</div>
|
| 559 |
</>
|
| 560 |
-
) :
|
| 561 |
-
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
| 562 |
-
<Users size={48} className="mb-2 opacity-20"/>
|
| 563 |
-
<p>请先在左侧选择一个队伍</p>
|
| 564 |
-
</div>
|
| 565 |
-
)}
|
| 566 |
</div>
|
| 567 |
</div>
|
| 568 |
</section>
|
| 569 |
</div>
|
| 570 |
</div>
|
| 571 |
-
|
| 572 |
<div className="p-4 border-t border-gray-100 bg-white rounded-b-2xl flex justify-end gap-3 shrink-0">
|
| 573 |
<button onClick={() => setIsSettingsOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl transition-colors font-medium">取消</button>
|
| 574 |
<button onClick={saveSettings} className="px-8 py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 shadow-lg shadow-blue-200 font-bold transition-all hover:scale-105 active:scale-95">保存配置</button>
|
|
@@ -578,9 +507,6 @@ export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
|
| 578 |
)}
|
| 579 |
</div>
|
| 580 |
);
|
| 581 |
-
|
| 582 |
-
if (isFullscreen) {
|
| 583 |
-
return createPortal(GameContent, document.body);
|
| 584 |
-
}
|
| 585 |
return GameContent;
|
| 586 |
};
|
|
|
|
| 6 |
import { Settings, Plus, Minus, Users, CheckSquare, Loader2, Trash2, X, Flag, Gift, Star, Trophy, Maximize, Minimize, Lock } from 'lucide-react';
|
| 7 |
import { Emoji } from '../components/Emoji';
|
| 8 |
|
| 9 |
+
// ... (Styles and Animations kept same)
|
| 10 |
const styles = `
|
| 11 |
+
@keyframes float-cloud { 0% { transform: translateX(0); } 50% { transform: translateX(20px); } 100% { transform: translateX(0); } }
|
| 12 |
+
@keyframes drift { from { transform: translateX(-100%); } to { transform: translateX(100vw); } }
|
| 13 |
+
@keyframes bounce-avatar { 0%, 100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-10px) scale(1.1); } }
|
| 14 |
+
@keyframes wave { 0% { transform: rotate(0deg); } 25% { transform: rotate(-10deg); } 75% { transform: rotate(10deg); } 100% { transform: rotate(0deg); } }
|
| 15 |
+
@keyframes confetti-fall { 0% { transform: translateY(-10px) rotate(0deg); opacity: 1; } 100% { transform: translateY(60px) rotate(360deg); opacity: 0; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
.animate-drift-slow { animation: drift 60s linear infinite; }
|
| 17 |
.animate-drift-medium { animation: drift 40s linear infinite; }
|
| 18 |
.animate-drift-fast { animation: drift 25s linear infinite; }
|
|
|
|
| 21 |
.animate-confetti { animation: confetti-fall 2s ease-out forwards; }
|
| 22 |
`;
|
| 23 |
|
| 24 |
+
// ... (Keep calculatePathPoint and generatePathString unchanged)
|
| 25 |
const calculatePathPoint = (progress: number) => {
|
| 26 |
const y = 8 + (progress * 80);
|
| 27 |
const amplitude = 15 * (1 - (progress * 0.3));
|
|
|
|
| 53 |
</div>
|
| 54 |
);
|
| 55 |
|
| 56 |
+
// ... (MountainStage Component - Keep mostly same, update display for count)
|
| 57 |
const MountainStage = ({ team, index, rewardsConfig, maxSteps, onScoreChange, isFullscreen }: {
|
| 58 |
team: GameTeam,
|
| 59 |
index: number,
|
|
|
|
| 70 |
|
| 71 |
return (
|
| 72 |
<div className={`relative flex flex-col items-center justify-end h-[95%] ${widthClass} mx-2 flex-shrink-0 select-none group perspective-1000 transition-all duration-300`}>
|
|
|
|
|
|
|
| 73 |
<div className="absolute top-[2%] z-20 transition-transform duration-300 hover:-translate-y-2 hover:scale-105 cursor-pointer w-full flex justify-center">
|
| 74 |
<div className="relative bg-white/90 backdrop-blur-md px-3 py-1.5 rounded-xl shadow-md border-2 border-white text-center max-w-[90%]">
|
| 75 |
<h3 className="text-sm md:text-base font-black text-slate-800 truncate">{team.name}</h3>
|
|
|
|
| 79 |
</div>
|
| 80 |
</div>
|
| 81 |
|
|
|
|
| 82 |
<div className="absolute bottom-0 left-0 w-full h-[90%] z-0 filter drop-shadow-lg transition-all duration-500 group-hover:drop-shadow-xl">
|
| 83 |
<svg viewBox="0 0 200 300" preserveAspectRatio="none" className="w-full h-full overflow-visible">
|
| 84 |
<defs>
|
|
|
|
| 100 |
</svg>
|
| 101 |
</div>
|
| 102 |
|
|
|
|
| 103 |
<div className="absolute bottom-0 left-0 w-full h-[90%] z-10 pointer-events-none">
|
| 104 |
{rewardsConfig.map((reward, i) => {
|
| 105 |
const rPct = reward.scoreThreshold / maxSteps;
|
|
|
|
| 116 |
{isUnlocked && <div className="absolute inset-0 bg-yellow-400 rounded-full blur-sm opacity-50 animate-pulse"></div>}
|
| 117 |
</div>
|
| 118 |
<div className={`mt-1 px-2 py-0.5 rounded text-[10px] font-bold shadow-sm whitespace-nowrap border max-w-[100px] truncate transition-colors ${isUnlocked ? 'bg-yellow-50 text-yellow-700 border-yellow-200' : 'bg-white/90 text-gray-500 border-gray-200'}`}>
|
| 119 |
+
{reward.rewardName} {reward.rewardValue > 1 ? `x${reward.rewardValue}` : ''}
|
| 120 |
</div>
|
| 121 |
</div>
|
| 122 |
);
|
| 123 |
})}
|
| 124 |
</div>
|
| 125 |
|
|
|
|
| 126 |
<div className="absolute z-30 transition-all duration-700 ease-in-out flex flex-col items-center -translate-x-1/2 transform translate-y-1/2" style={{ bottom: `${currentPos.y}%`, left: `${currentPos.x}%` }}>
|
| 127 |
<div className={`w-12 h-12 md:w-14 md:h-14 bg-white rounded-full border-4 shadow-xl flex items-center justify-center transition-transform relative ${isFinished ? 'animate-bounce border-yellow-400' : 'hover:scale-110'}`} style={{ borderColor: isFinished ? '#facc15' : team.color }}>
|
| 128 |
<span className="text-2xl md:text-3xl"><Emoji symbol={team.avatar || '🧗'} /></span>
|
|
|
|
| 131 |
</div>
|
| 132 |
</div>
|
| 133 |
|
|
|
|
| 134 |
{onScoreChange && (
|
| 135 |
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-all z-40 bg-white/90 backdrop-blur px-2 py-1 rounded-full shadow-lg border border-gray-200 hover:scale-105">
|
| 136 |
<button onClick={() => onScoreChange(team.id, -1)} className="p-1 rounded-full bg-slate-100 text-slate-500 hover:bg-red-100 hover:text-red-600"><Minus size={14}/></button>
|
|
|
|
| 141 |
);
|
| 142 |
};
|
| 143 |
|
| 144 |
+
// ... (GameToast - Keep same)
|
| 145 |
const GameToast = ({ title, message, type, onClose }: { title: string, message: string, type: 'success' | 'info', onClose: () => void }) => {
|
| 146 |
useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]);
|
| 147 |
return (
|
|
|
|
| 155 |
};
|
| 156 |
|
| 157 |
export const GameMountain: React.FC<{className?: string}> = ({ className }) => {
|
| 158 |
+
// ... (State - Keep same)
|
| 159 |
const [session, setSession] = useState<GameSession | null>(null);
|
| 160 |
const [loading, setLoading] = useState(true);
|
| 161 |
const [students, setStudents] = useState<Student[]>([]);
|
|
|
|
| 162 |
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 163 |
const [isFullscreen, setIsFullscreen] = useState(false);
|
| 164 |
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
| 165 |
const [achConfig, setAchConfig] = useState<AchievementConfig | null>(null);
|
| 166 |
+
const [canEdit, setCanEdit] = useState(false);
|
|
|
|
| 167 |
const [toast, setToast] = useState<{title: string, message: string, type: 'success' | 'info'} | null>(null);
|
| 168 |
|
| 169 |
const currentUser = api.auth.getCurrentUser();
|
| 170 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 171 |
const isAdmin = currentUser?.role === 'ADMIN';
|
|
|
|
|
|
|
| 172 |
const targetClass = className || currentUser?.homeroomClass || (currentUser?.role === 'STUDENT' ? 'MY_CLASS' : '');
|
| 173 |
|
| 174 |
useEffect(() => { loadData(); }, [targetClass]);
|
| 175 |
|
| 176 |
const loadData = async () => {
|
|
|
|
| 177 |
if (!targetClass || targetClass === 'MY_CLASS' && !currentUser?.homeroomClass && currentUser?.role !== 'STUDENT') return;
|
| 178 |
setLoading(true);
|
|
|
|
|
|
|
| 179 |
let resolvedClass = targetClass;
|
| 180 |
if (targetClass === 'MY_CLASS' && currentUser?.role === 'STUDENT') {
|
|
|
|
|
|
|
| 181 |
const stus = await api.students.getAll();
|
| 182 |
const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
|
| 183 |
if(me) resolvedClass = me.className;
|
|
|
|
| 190 |
]);
|
| 191 |
|
| 192 |
const filteredStudents = allStudents.filter((s: Student) => s.className === resolvedClass);
|
|
|
|
|
|
|
| 193 |
filteredStudents.sort((a: Student, b: Student) => {
|
| 194 |
const seatA = parseInt(a.seatNo || '99999');
|
| 195 |
const seatB = parseInt(b.seatNo || '99999');
|
|
|
|
| 201 |
if (sess) {
|
| 202 |
setSession(sess);
|
| 203 |
} else if (isTeacher && currentUser?.schoolId) {
|
|
|
|
| 204 |
const newSess: GameSession = {
|
| 205 |
schoolId: currentUser.schoolId,
|
| 206 |
className: resolvedClass,
|
|
|
|
| 213 |
{ id: '3', name: '飞龙队', score: 0, avatar: '🐉', color: '#10b981', members: [] }
|
| 214 |
],
|
| 215 |
rewardsConfig: [
|
| 216 |
+
{ scoreThreshold: 5, rewardType: 'DRAW_COUNT', rewardName: '抽奖券', rewardValue: 1 },
|
| 217 |
{ scoreThreshold: 10, rewardType: 'ITEM', rewardName: '神秘大礼', rewardValue: 1 }
|
| 218 |
]
|
| 219 |
};
|
| 220 |
setSession(newSess);
|
| 221 |
}
|
| 222 |
|
|
|
|
| 223 |
if (isAdmin) setCanEdit(true);
|
| 224 |
else if (isTeacher && currentUser) {
|
|
|
|
| 225 |
const clsList = await api.classes.getAll() as ClassInfo[];
|
|
|
|
| 226 |
const cls = clsList.find((c: ClassInfo) => c.grade + c.className === resolvedClass);
|
| 227 |
if (cls && (cls.homeroomTeacherIds?.includes(currentUser._id || '') || cls.teacherName?.includes(currentUser.trueName || currentUser.username))) {
|
| 228 |
setCanEdit(true);
|
|
|
|
| 229 |
const ac = await api.achievements.getConfig(resolvedClass);
|
| 230 |
setAchConfig(ac);
|
| 231 |
+
} else { setCanEdit(false); }
|
| 232 |
+
} else { setCanEdit(false); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
} catch (e) { console.error(e); }
|
| 235 |
finally { setLoading(false); }
|
|
|
|
| 249 |
if (newScore === session.maxSteps) {
|
| 250 |
setToast({ title: `🏆 巅峰时刻!`, message: `恭喜 [${t.name}] 成功登顶!`, type: 'success' });
|
| 251 |
} else if (reward) {
|
| 252 |
+
setToast({ title: `🎉 触发奖励!`, message: `[${t.name}] 获得:${reward.rewardName} x${reward.rewardValue || 1}`, type: 'info' });
|
| 253 |
}
|
| 254 |
|
| 255 |
if (reward) {
|
|
|
|
| 269 |
studentName: stu.name,
|
| 270 |
rewardType: reward.rewardType as any,
|
| 271 |
name: reward.rewardName,
|
| 272 |
+
count: reward.rewardValue || 1, // USE REWARD VALUE
|
| 273 |
status: 'PENDING',
|
| 274 |
source: `群岳争锋 - ${t.name} ${newScore}步`
|
| 275 |
});
|
|
|
|
| 288 |
await api.games.saveMountainSession(newSession);
|
| 289 |
} catch(e) {
|
| 290 |
alert('保存失败:您可能没有权限修改此班级数据');
|
| 291 |
+
loadData();
|
| 292 |
}
|
| 293 |
};
|
| 294 |
|
|
|
|
| 322 |
const GameContent = (
|
| 323 |
<div className={`${isFullscreen ? 'fixed inset-0 z-[9999] w-screen h-screen' : 'h-full w-full relative'} flex flex-col bg-gradient-to-b from-sky-300 via-sky-100 to-emerald-50 overflow-hidden`}>
|
| 324 |
<style>{styles}</style>
|
| 325 |
+
{/* Background */}
|
|
|
|
| 326 |
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
| 327 |
<div className="absolute top-10 right-20 w-24 h-24 bg-yellow-300 rounded-full blur-xl opacity-60 animate-pulse"></div>
|
| 328 |
<div className="absolute top-16 -left-20 text-white/60 text-9xl select-none animate-drift-slow opacity-80" style={{filter: 'blur(2px)'}}><Emoji symbol="☁️"/></div>
|
| 329 |
<div className="absolute top-32 -left-40 text-white/40 text-8xl select-none animate-drift-medium opacity-60" style={{animationDelay: '5s'}}><Emoji symbol="☁️"/></div>
|
| 330 |
</div>
|
|
|
|
|
|
|
| 331 |
{toast && <GameToast title={toast.title} message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
|
|
|
| 332 |
{/* Toolbar */}
|
| 333 |
<div className="absolute top-4 right-4 z-50 flex gap-2">
|
| 334 |
+
{!canEdit && isTeacher && <div className="px-3 py-1.5 bg-gray-100/80 backdrop-blur rounded-full text-xs font-bold text-gray-500 flex items-center border border-gray-200"><Lock size={14} className="mr-1"/> 只读模式 (非班主任)</div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
<button onClick={() => setIsFullscreen(!isFullscreen)} className="p-2 bg-white/80 backdrop-blur rounded-full hover:bg-white shadow-sm border border-white/50 transition-colors">
|
| 336 |
{isFullscreen ? <Minimize size={20} className="text-gray-700"/> : <Maximize size={20} className="text-gray-700"/>}
|
| 337 |
</button>
|
| 338 |
+
{canEdit && <button onClick={() => setIsSettingsOpen(true)} className="flex items-center text-xs font-bold text-slate-700 bg-white/90 backdrop-blur px-4 py-2 rounded-2xl border border-white/50 hover:bg-white shadow-md transition-all hover:scale-105 active:scale-95"><Settings size={16} className="mr-2"/> 设置</button>}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
</div>
|
| 340 |
+
{/* Game Area */}
|
|
|
|
| 341 |
<div className="flex-1 overflow-x-auto overflow-y-hidden relative custom-scrollbar z-10 w-full min-w-0">
|
| 342 |
<div className="h-full flex items-end px-10 pb-4 gap-2 mx-auto w-max min-w-full justify-center">
|
| 343 |
{session.teams.map((team, idx) => (
|
| 344 |
+
<MountainStage key={team.id} team={team} index={idx} rewardsConfig={session.rewardsConfig} maxSteps={session.maxSteps} onScoreChange={canEdit ? handleScoreChange : undefined} isFullscreen={isFullscreen} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
))}
|
| 346 |
</div>
|
| 347 |
</div>
|
|
|
|
| 348 |
{/* SETTINGS MODAL */}
|
| 349 |
{isSettingsOpen && canEdit && (
|
| 350 |
<div className="fixed inset-0 bg-black/60 z-[100000] flex items-center justify-center p-4 backdrop-blur-sm">
|
|
|
|
| 353 |
<h3 className="text-xl font-bold text-gray-800 flex items-center"><Settings className="mr-2 text-blue-600"/> 游戏控制台</h3>
|
| 354 |
<button onClick={() => setIsSettingsOpen(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 355 |
</div>
|
|
|
|
| 356 |
<div className="flex-1 overflow-y-auto p-6 space-y-8 bg-gray-50/50">
|
| 357 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 358 |
{/* Basic Config */}
|
|
|
|
| 365 |
</div>
|
| 366 |
</div>
|
| 367 |
</section>
|
| 368 |
+
{/* Rewards Config - ADDED QUANTITY INPUT */}
|
|
|
|
| 369 |
<section className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm row-span-2">
|
| 370 |
<h4 className="font-bold text-gray-700 mb-4 flex items-center text-sm uppercase tracking-wide"><Gift size={16} className="mr-2 text-amber-500"/> 奖励节点</h4>
|
| 371 |
<div className="space-y-2 max-h-64 overflow-y-auto pr-1 custom-scrollbar">
|
|
|
|
| 386 |
<option value="ITEM">🎁 实物</option>
|
| 387 |
<option value="ACHIEVEMENT">🏆 奖状/成就</option>
|
| 388 |
</select>
|
| 389 |
+
{/* QUANTITY INPUT */}
|
| 390 |
+
<div className="flex items-center gap-1 bg-white border px-2 py-1 rounded">
|
| 391 |
+
<span className="text-xs text-gray-400">x</span>
|
| 392 |
+
<input type="number" min={1} className="w-8 text-center font-bold text-sm outline-none" value={rc.rewardValue || 1} onChange={e => {
|
| 393 |
+
const newArr = [...session.rewardsConfig]; newArr[idx].rewardValue = Number(e.target.value); setSession({...session, rewardsConfig: newArr});
|
| 394 |
+
}}/>
|
| 395 |
+
</div>
|
| 396 |
<button onClick={() => {
|
| 397 |
const newArr = session.rewardsConfig.filter((_, i) => i !== idx); setSession({...session, rewardsConfig: newArr});
|
| 398 |
}} className="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 size={16}/></button>
|
| 399 |
</div>
|
|
|
|
| 400 |
{rc.rewardType === 'ACHIEVEMENT' ? (
|
| 401 |
<select className="w-full text-xs border border-gray-200 rounded p-1 bg-white" value={rc.achievementId || ''} onChange={e => {
|
| 402 |
const newArr = [...session.rewardsConfig];
|
|
|
|
| 418 |
</div>
|
| 419 |
<button onClick={() => setSession({...session, rewardsConfig: [...session.rewardsConfig, { scoreThreshold: session.maxSteps, rewardType: 'DRAW_COUNT', rewardName: '奖励', rewardValue: 1 }]})} className="mt-3 w-full py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-blue-400 hover:text-blue-600 transition-colors">+ 添加节点</button>
|
| 420 |
</section>
|
|
|
|
| 421 |
{/* Team Management */}
|
| 422 |
<section className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm md:col-span-2">
|
| 423 |
<div className="flex justify-between items-center mb-4">
|
|
|
|
| 427 |
setSession({ ...session, teams: [...session.teams, newTeam] });
|
| 428 |
}} className="text-xs bg-emerald-50 text-emerald-600 px-3 py-1.5 rounded-lg hover:bg-emerald-100 border border-emerald-200 font-bold transition-colors">+ 新建队伍</button>
|
| 429 |
</div>
|
|
|
|
| 430 |
<div className="flex flex-col md:flex-row gap-6 h-[400px]">
|
|
|
|
| 431 |
<div className="w-full md:w-1/3 space-y-3 overflow-y-auto pr-2 custom-scrollbar">
|
| 432 |
{session.teams.map(t => (
|
| 433 |
<div key={t.id}
|
|
|
|
| 459 |
</div>
|
| 460 |
))}
|
| 461 |
</div>
|
|
|
|
|
|
|
| 462 |
<div className="flex-1 bg-gray-50 rounded-xl border border-gray-200 p-4 flex flex-col shadow-inner">
|
| 463 |
{selectedTeamId ? (
|
| 464 |
<>
|
|
|
|
| 475 |
const isInCurrent = currentTeamId === selectedTeamId;
|
| 476 |
const isInOther = currentTeamId && !isInCurrent;
|
| 477 |
const otherTeam = isInOther ? session.teams.find(t => t.id === currentTeamId) : null;
|
|
|
|
| 478 |
return (
|
| 479 |
<div key={s._id}
|
| 480 |
onClick={() => toggleTeamMember(s._id || String(s.id), selectedTeamId)}
|
|
|
|
| 492 |
})}
|
| 493 |
</div>
|
| 494 |
</>
|
| 495 |
+
) : <div className="flex flex-col items-center justify-center h-full text-gray-400"><Users size={48} className="mb-2 opacity-20"/><p>请先在左侧选择一个队伍</p></div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
</div>
|
| 497 |
</div>
|
| 498 |
</section>
|
| 499 |
</div>
|
| 500 |
</div>
|
|
|
|
| 501 |
<div className="p-4 border-t border-gray-100 bg-white rounded-b-2xl flex justify-end gap-3 shrink-0">
|
| 502 |
<button onClick={() => setIsSettingsOpen(false)} className="px-5 py-2.5 text-gray-600 hover:bg-gray-100 rounded-xl transition-colors font-medium">取消</button>
|
| 503 |
<button onClick={saveSettings} className="px-8 py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 shadow-lg shadow-blue-200 font-bold transition-all hover:scale-105 active:scale-95">保存配置</button>
|
|
|
|
| 507 |
)}
|
| 508 |
</div>
|
| 509 |
);
|
| 510 |
+
if (isFullscreen) { return createPortal(GameContent, document.body); }
|
|
|
|
|
|
|
|
|
|
| 511 |
return GameContent;
|
| 512 |
};
|
pages/GameRewards.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react';
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { StudentReward, Student } from '../types';
|
| 5 |
import { Gift, Loader2, Search, Filter, Trash2, Edit, Save, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
| 6 |
|
| 7 |
export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
| 8 |
const [rewards, setRewards] = useState<StudentReward[]>([]);
|
|
@@ -10,7 +11,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 10 |
const [page, setPage] = useState(1);
|
| 11 |
const [loading, setLoading] = useState(true);
|
| 12 |
|
| 13 |
-
// Grant Modal
|
| 14 |
const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
|
| 15 |
const [students, setStudents] = useState<Student[]>([]);
|
| 16 |
const [grantForm, setGrantForm] = useState({
|
|
@@ -20,54 +20,47 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 20 |
name: ''
|
| 21 |
});
|
| 22 |
|
| 23 |
-
|
| 24 |
-
const [
|
| 25 |
-
const [filterStatus, setFilterStatus] = useState('ALL'); // ALL, PENDING, REDEEMED
|
| 26 |
const [searchText, setSearchText] = useState('');
|
| 27 |
|
| 28 |
-
// Edit State
|
| 29 |
const [editingId, setEditingId] = useState<string | null>(null);
|
| 30 |
const [editForm, setEditForm] = useState({ name: '', count: 1 });
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
const currentUser = api.auth.getCurrentUser();
|
| 33 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 34 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 35 |
|
| 36 |
-
// Use prop className or fallback to legacy homeroom
|
| 37 |
const homeroomClass = className || currentUser?.homeroomClass;
|
| 38 |
-
|
| 39 |
const PAGE_SIZE = 15;
|
| 40 |
|
| 41 |
const loadData = async () => {
|
| 42 |
setLoading(true);
|
| 43 |
try {
|
| 44 |
if (isStudent) {
|
| 45 |
-
// Student View
|
| 46 |
const stus = await api.students.getAll();
|
| 47 |
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 48 |
if (me) {
|
| 49 |
const res = await api.rewards.getMyRewards(me._id || String(me.id), page, PAGE_SIZE);
|
| 50 |
-
setRewards(res.list || res);
|
| 51 |
setTotal(res.total || (Array.isArray(res) ? res.length : 0));
|
| 52 |
}
|
| 53 |
} else {
|
| 54 |
-
// Teacher View
|
| 55 |
const allStus = await api.students.getAll();
|
| 56 |
-
|
| 57 |
let targetClass = '';
|
| 58 |
let filteredStudents = allStus;
|
| 59 |
-
|
| 60 |
if (isTeacher && homeroomClass) {
|
| 61 |
targetClass = homeroomClass;
|
| 62 |
filteredStudents = allStus.filter((s: Student) => s.className === homeroomClass);
|
| 63 |
}
|
| 64 |
-
|
| 65 |
const res = await api.rewards.getClassRewards(page, PAGE_SIZE, targetClass);
|
| 66 |
-
|
| 67 |
setRewards(res.list || []);
|
| 68 |
setTotal(res.total || 0);
|
| 69 |
-
|
| 70 |
-
// Sort students: Seat No > Name
|
| 71 |
filteredStudents.sort((a: Student, b: Student) => {
|
| 72 |
const seatA = parseInt(a.seatNo || '99999');
|
| 73 |
const seatB = parseInt(b.seatNo || '99999');
|
|
@@ -80,12 +73,11 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 80 |
finally { setLoading(false); }
|
| 81 |
};
|
| 82 |
|
| 83 |
-
useEffect(() => { loadData(); }, [page, className]);
|
| 84 |
|
| 85 |
const handleGrant = async () => {
|
| 86 |
if(!grantForm.studentId) return alert('请选择学生');
|
| 87 |
if(grantForm.rewardType === 'ITEM' && !grantForm.name) return alert('请输入奖品名称');
|
| 88 |
-
|
| 89 |
try {
|
| 90 |
await api.games.grantReward(grantForm);
|
| 91 |
setIsGrantModalOpen(false);
|
|
@@ -95,21 +87,31 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 95 |
} catch(e) { alert('发放失败'); }
|
| 96 |
};
|
| 97 |
|
| 98 |
-
const
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
};
|
| 104 |
|
| 105 |
-
const
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
};
|
| 114 |
|
| 115 |
const handleUpdate = async (id: string) => {
|
|
@@ -123,7 +125,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 123 |
setEditForm({ name: r.name, count: r.count || 1 });
|
| 124 |
};
|
| 125 |
|
| 126 |
-
// Client-side Filtering
|
| 127 |
const displayRewards = rewards.filter(r => {
|
| 128 |
if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
|
| 129 |
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
|
|
@@ -140,13 +141,18 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 140 |
|
| 141 |
return (
|
| 142 |
<div className="flex flex-col h-full bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
<div className="p-4 md:p-6 border-b border-gray-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0">
|
| 144 |
-
<h3 className="text-xl font-bold text-gray-800">
|
| 145 |
-
{isStudent ? '我的战利品清单' : `${homeroomClass || '全校'} 奖励核销台`}
|
| 146 |
-
</h3>
|
| 147 |
-
|
| 148 |
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
|
| 149 |
-
{/* Filters */}
|
| 150 |
<div className="flex items-center bg-gray-100 rounded-lg p-1">
|
| 151 |
<select className="bg-transparent text-xs p-1.5 rounded outline-none text-gray-600" value={filterType} onChange={e=>setFilterType(e.target.value)}>
|
| 152 |
<option value="ALL">全部类型</option>
|
|
@@ -165,7 +171,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 165 |
<input className="pl-8 pr-3 py-1.5 text-xs border rounded-lg bg-gray-50 focus:bg-white transition-colors outline-none w-32" placeholder="搜索..." value={searchText} onChange={e=>setSearchText(e.target.value)}/>
|
| 166 |
<Search className="absolute left-2.5 top-2 text-gray-400" size={12}/>
|
| 167 |
</div>
|
| 168 |
-
|
| 169 |
{!isStudent && (
|
| 170 |
<button onClick={() => setIsGrantModalOpen(true)} className="flex items-center px-4 py-2 bg-amber-100 text-amber-700 rounded-lg font-bold hover:bg-amber-200 text-sm ml-auto md:ml-0">
|
| 171 |
<Gift size={16} className="mr-2"/> 发放奖励
|
|
@@ -174,7 +179,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 174 |
</div>
|
| 175 |
</div>
|
| 176 |
|
| 177 |
-
{/* Scrollable List Container */}
|
| 178 |
<div className="flex-1 overflow-y-auto p-0 min-h-0">
|
| 179 |
<table className="w-full text-left border-collapse">
|
| 180 |
<thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
|
|
@@ -201,32 +205,12 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 201 |
<span className="text-xs text-gray-400">x</span>
|
| 202 |
<input type="number" min={1} className="border rounded px-1 py-0.5 text-xs w-12" value={editForm.count} onChange={e=>setEditForm({...editForm, count:Number(e.target.value)})}/>
|
| 203 |
</div>
|
| 204 |
-
) :
|
| 205 |
-
<span>{r.name} <span className="text-gray-400 text-xs ml-1">x{r.count || 1}</span></span>
|
| 206 |
-
)}
|
| 207 |
-
</td>
|
| 208 |
-
<td className="p-4">
|
| 209 |
-
<span className={`text-xs px-2 py-1 rounded border ${
|
| 210 |
-
r.rewardType === 'DRAW_COUNT' ? 'bg-purple-50 text-purple-700 border-purple-100' :
|
| 211 |
-
r.rewardType === 'CONSOLATION' ? 'bg-gray-100 text-gray-500 border-gray-200' :
|
| 212 |
-
'bg-blue-50 text-blue-700 border-blue-100'
|
| 213 |
-
}`}>
|
| 214 |
-
{r.rewardType==='DRAW_COUNT' ? '抽奖券' : r.rewardType==='CONSOLATION' ? '未中奖' : '实物'}
|
| 215 |
-
</span>
|
| 216 |
</td>
|
|
|
|
| 217 |
<td className="p-4 text-gray-500 text-xs">{r.source}</td>
|
| 218 |
<td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
|
| 219 |
-
<td className="p-4">
|
| 220 |
-
{r.rewardType === 'DRAW_COUNT' ? (
|
| 221 |
-
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded border border-purple-200">系统入账</span>
|
| 222 |
-
) : r.rewardType === 'CONSOLATION' ? (
|
| 223 |
-
<span className="text-xs text-gray-400">已结束</span>
|
| 224 |
-
) : (
|
| 225 |
-
r.status === 'REDEEMED'
|
| 226 |
-
? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span>
|
| 227 |
-
: <span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded border border-amber-200 animate-pulse">待核销</span>
|
| 228 |
-
)}
|
| 229 |
-
</td>
|
| 230 |
{!isStudent && (
|
| 231 |
<td className="p-4 text-right flex justify-end gap-2">
|
| 232 |
{isEditing ? (
|
|
@@ -237,12 +221,10 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 237 |
) : (
|
| 238 |
<>
|
| 239 |
{r.status !== 'REDEEMED' && r.rewardType === 'ITEM' && (
|
| 240 |
-
<button onClick={() =>
|
| 241 |
-
核销
|
| 242 |
-
</button>
|
| 243 |
)}
|
| 244 |
<button onClick={()=>startEdit(r)} className="text-blue-400 hover:text-blue-600 p-1" title="编辑"><Edit size={16}/></button>
|
| 245 |
-
<button onClick={()=>
|
| 246 |
</>
|
| 247 |
)}
|
| 248 |
</td>
|
|
@@ -250,14 +232,10 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 250 |
</tr>
|
| 251 |
);
|
| 252 |
})}
|
| 253 |
-
{displayRewards.length === 0 &&
|
| 254 |
-
<tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>
|
| 255 |
-
)}
|
| 256 |
</tbody>
|
| 257 |
</table>
|
| 258 |
</div>
|
| 259 |
-
|
| 260 |
-
{/* Pagination Footer */}
|
| 261 |
<div className="p-3 border-t border-gray-100 flex items-center justify-between shrink-0 bg-gray-50">
|
| 262 |
<span className="text-xs text-gray-500">共 {total} 条记录</span>
|
| 263 |
<div className="flex items-center gap-2">
|
|
@@ -266,8 +244,6 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 266 |
<button onClick={()=>setPage(Math.min(totalPages, page+1))} disabled={page>=totalPages} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronRight size={16}/></button>
|
| 267 |
</div>
|
| 268 |
</div>
|
| 269 |
-
|
| 270 |
-
{/* Grant Modal */}
|
| 271 |
{isGrantModalOpen && (
|
| 272 |
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
|
| 273 |
<div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in zoom-in-95 shadow-2xl">
|
|
@@ -287,19 +263,16 @@ export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
|
| 287 |
<button onClick={()=>setGrantForm({...grantForm, rewardType: 'ITEM'})} className={`flex-1 py-2 text-sm rounded border ${grantForm.rewardType==='ITEM' ? 'bg-blue-50 border-blue-500 text-blue-700 font-bold' : 'border-gray-200 text-gray-600'}`}>实物奖品</button>
|
| 288 |
</div>
|
| 289 |
</div>
|
| 290 |
-
|
| 291 |
{grantForm.rewardType === 'ITEM' && (
|
| 292 |
<div>
|
| 293 |
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">奖品名称</label>
|
| 294 |
<input className="w-full border border-gray-300 p-2 rounded-lg text-sm" placeholder="如: 笔记本、铅笔" value={grantForm.name} onChange={e=>setGrantForm({...grantForm, name: e.target.value})}/>
|
| 295 |
</div>
|
| 296 |
)}
|
| 297 |
-
|
| 298 |
<div>
|
| 299 |
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">发放数量</label>
|
| 300 |
<input type="number" min={1} className="w-full border border-gray-300 p-2 rounded-lg outline-none" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
|
| 301 |
</div>
|
| 302 |
-
|
| 303 |
<div className="flex gap-2 pt-2">
|
| 304 |
<button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
|
| 305 |
<button onClick={handleGrant} className="flex-1 bg-amber-500 text-white py-2 rounded-lg font-bold hover:bg-amber-600 shadow-md transition-colors">确认发放</button>
|
|
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { StudentReward, Student } from '../types';
|
| 5 |
import { Gift, Loader2, Search, Filter, Trash2, Edit, Save, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
| 6 |
+
import { ConfirmModal } from '../components/ConfirmModal';
|
| 7 |
|
| 8 |
export const GameRewards: React.FC<{className?: string}> = ({ className }) => {
|
| 9 |
const [rewards, setRewards] = useState<StudentReward[]>([]);
|
|
|
|
| 11 |
const [page, setPage] = useState(1);
|
| 12 |
const [loading, setLoading] = useState(true);
|
| 13 |
|
|
|
|
| 14 |
const [isGrantModalOpen, setIsGrantModalOpen] = useState(false);
|
| 15 |
const [students, setStudents] = useState<Student[]>([]);
|
| 16 |
const [grantForm, setGrantForm] = useState({
|
|
|
|
| 20 |
name: ''
|
| 21 |
});
|
| 22 |
|
| 23 |
+
const [filterType, setFilterType] = useState('ALL');
|
| 24 |
+
const [filterStatus, setFilterStatus] = useState('ALL');
|
|
|
|
| 25 |
const [searchText, setSearchText] = useState('');
|
| 26 |
|
|
|
|
| 27 |
const [editingId, setEditingId] = useState<string | null>(null);
|
| 28 |
const [editForm, setEditForm] = useState({ name: '', count: 1 });
|
| 29 |
|
| 30 |
+
// Modal State
|
| 31 |
+
const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void, isDanger?: boolean}>({
|
| 32 |
+
isOpen: false, title: '', message: '', onConfirm: () => {}
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
const currentUser = api.auth.getCurrentUser();
|
| 36 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 37 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 38 |
|
|
|
|
| 39 |
const homeroomClass = className || currentUser?.homeroomClass;
|
|
|
|
| 40 |
const PAGE_SIZE = 15;
|
| 41 |
|
| 42 |
const loadData = async () => {
|
| 43 |
setLoading(true);
|
| 44 |
try {
|
| 45 |
if (isStudent) {
|
|
|
|
| 46 |
const stus = await api.students.getAll();
|
| 47 |
const me = stus.find((s: Student) => s.name === (currentUser.trueName || currentUser.username));
|
| 48 |
if (me) {
|
| 49 |
const res = await api.rewards.getMyRewards(me._id || String(me.id), page, PAGE_SIZE);
|
| 50 |
+
setRewards(res.list || res);
|
| 51 |
setTotal(res.total || (Array.isArray(res) ? res.length : 0));
|
| 52 |
}
|
| 53 |
} else {
|
|
|
|
| 54 |
const allStus = await api.students.getAll();
|
|
|
|
| 55 |
let targetClass = '';
|
| 56 |
let filteredStudents = allStus;
|
|
|
|
| 57 |
if (isTeacher && homeroomClass) {
|
| 58 |
targetClass = homeroomClass;
|
| 59 |
filteredStudents = allStus.filter((s: Student) => s.className === homeroomClass);
|
| 60 |
}
|
|
|
|
| 61 |
const res = await api.rewards.getClassRewards(page, PAGE_SIZE, targetClass);
|
|
|
|
| 62 |
setRewards(res.list || []);
|
| 63 |
setTotal(res.total || 0);
|
|
|
|
|
|
|
| 64 |
filteredStudents.sort((a: Student, b: Student) => {
|
| 65 |
const seatA = parseInt(a.seatNo || '99999');
|
| 66 |
const seatB = parseInt(b.seatNo || '99999');
|
|
|
|
| 73 |
finally { setLoading(false); }
|
| 74 |
};
|
| 75 |
|
| 76 |
+
useEffect(() => { loadData(); }, [page, className]);
|
| 77 |
|
| 78 |
const handleGrant = async () => {
|
| 79 |
if(!grantForm.studentId) return alert('请选择学生');
|
| 80 |
if(grantForm.rewardType === 'ITEM' && !grantForm.name) return alert('请输入奖品名称');
|
|
|
|
| 81 |
try {
|
| 82 |
await api.games.grantReward(grantForm);
|
| 83 |
setIsGrantModalOpen(false);
|
|
|
|
| 87 |
} catch(e) { alert('发放失败'); }
|
| 88 |
};
|
| 89 |
|
| 90 |
+
const openRedeemConfirm = (id: string) => {
|
| 91 |
+
setConfirmModal({
|
| 92 |
+
isOpen: true,
|
| 93 |
+
title: '确认核销',
|
| 94 |
+
message: '确定要将此奖励标记为“已核销/已兑换”吗?此操作不可撤销。',
|
| 95 |
+
onConfirm: async () => {
|
| 96 |
+
await api.rewards.redeem(id);
|
| 97 |
+
loadData();
|
| 98 |
+
}
|
| 99 |
+
});
|
| 100 |
};
|
| 101 |
|
| 102 |
+
const openDeleteConfirm = (r: StudentReward) => {
|
| 103 |
+
setConfirmModal({
|
| 104 |
+
isOpen: true,
|
| 105 |
+
title: '撤回奖励',
|
| 106 |
+
message: `确定要撤回 "${r.name}" 吗?如果学生已经使用了抽奖券,撤回将失败。`,
|
| 107 |
+
isDanger: true,
|
| 108 |
+
onConfirm: async () => {
|
| 109 |
+
try {
|
| 110 |
+
await api.rewards.delete(r._id!);
|
| 111 |
+
loadData();
|
| 112 |
+
} catch(e: any) { alert(e.message || '撤回失败'); }
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
};
|
| 116 |
|
| 117 |
const handleUpdate = async (id: string) => {
|
|
|
|
| 125 |
setEditForm({ name: r.name, count: r.count || 1 });
|
| 126 |
};
|
| 127 |
|
|
|
|
| 128 |
const displayRewards = rewards.filter(r => {
|
| 129 |
if (filterType !== 'ALL' && r.rewardType !== filterType) return false;
|
| 130 |
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false;
|
|
|
|
| 141 |
|
| 142 |
return (
|
| 143 |
<div className="flex flex-col h-full bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
| 144 |
+
<ConfirmModal
|
| 145 |
+
isOpen={confirmModal.isOpen}
|
| 146 |
+
title={confirmModal.title}
|
| 147 |
+
message={confirmModal.message}
|
| 148 |
+
onClose={()=>setConfirmModal({...confirmModal, isOpen: false})}
|
| 149 |
+
onConfirm={confirmModal.onConfirm}
|
| 150 |
+
isDanger={confirmModal.isDanger}
|
| 151 |
+
/>
|
| 152 |
+
|
| 153 |
<div className="p-4 md:p-6 border-b border-gray-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 shrink-0">
|
| 154 |
+
<h3 className="text-xl font-bold text-gray-800">{isStudent ? '我的战利品清单' : `${homeroomClass || '全校'} 奖励核销台`}</h3>
|
|
|
|
|
|
|
|
|
|
| 155 |
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
|
|
|
|
| 156 |
<div className="flex items-center bg-gray-100 rounded-lg p-1">
|
| 157 |
<select className="bg-transparent text-xs p-1.5 rounded outline-none text-gray-600" value={filterType} onChange={e=>setFilterType(e.target.value)}>
|
| 158 |
<option value="ALL">全部类型</option>
|
|
|
|
| 171 |
<input className="pl-8 pr-3 py-1.5 text-xs border rounded-lg bg-gray-50 focus:bg-white transition-colors outline-none w-32" placeholder="搜索..." value={searchText} onChange={e=>setSearchText(e.target.value)}/>
|
| 172 |
<Search className="absolute left-2.5 top-2 text-gray-400" size={12}/>
|
| 173 |
</div>
|
|
|
|
| 174 |
{!isStudent && (
|
| 175 |
<button onClick={() => setIsGrantModalOpen(true)} className="flex items-center px-4 py-2 bg-amber-100 text-amber-700 rounded-lg font-bold hover:bg-amber-200 text-sm ml-auto md:ml-0">
|
| 176 |
<Gift size={16} className="mr-2"/> 发放奖励
|
|
|
|
| 179 |
</div>
|
| 180 |
</div>
|
| 181 |
|
|
|
|
| 182 |
<div className="flex-1 overflow-y-auto p-0 min-h-0">
|
| 183 |
<table className="w-full text-left border-collapse">
|
| 184 |
<thead className="bg-gray-50 text-gray-500 text-xs uppercase sticky top-0 z-10 shadow-sm">
|
|
|
|
| 205 |
<span className="text-xs text-gray-400">x</span>
|
| 206 |
<input type="number" min={1} className="border rounded px-1 py-0.5 text-xs w-12" value={editForm.count} onChange={e=>setEditForm({...editForm, count:Number(e.target.value)})}/>
|
| 207 |
</div>
|
| 208 |
+
) : <span>{r.name} <span className="text-gray-400 text-xs ml-1">x{r.count || 1}</span></span>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
</td>
|
| 210 |
+
<td className="p-4"><span className={`text-xs px-2 py-1 rounded border ${r.rewardType === 'DRAW_COUNT' ? 'bg-purple-50 text-purple-700 border-purple-100' : r.rewardType === 'CONSOLATION' ? 'bg-gray-100 text-gray-500 border-gray-200' : 'bg-blue-50 text-blue-700 border-blue-100'}`}>{r.rewardType==='DRAW_COUNT' ? '抽奖券' : r.rewardType==='CONSOLATION' ? '未中奖' : '实物'}</span></td>
|
| 211 |
<td className="p-4 text-gray-500 text-xs">{r.source}</td>
|
| 212 |
<td className="p-4 text-gray-500 text-xs">{new Date(r.createTime).toLocaleDateString()}</td>
|
| 213 |
+
<td className="p-4">{r.rewardType === 'DRAW_COUNT' ? <span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded border border-purple-200">系统入账</span> : r.rewardType === 'CONSOLATION' ? <span className="text-xs text-gray-400">已结束</span> : (r.status === 'REDEEMED' ? <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded border border-green-200">已兑换</span> : <span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded border border-amber-200 animate-pulse">待核销</span>)}</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
{!isStudent && (
|
| 215 |
<td className="p-4 text-right flex justify-end gap-2">
|
| 216 |
{isEditing ? (
|
|
|
|
| 221 |
) : (
|
| 222 |
<>
|
| 223 |
{r.status !== 'REDEEMED' && r.rewardType === 'ITEM' && (
|
| 224 |
+
<button onClick={() => openRedeemConfirm(r._id!)} className="text-xs bg-green-600 text-white px-3 py-1 rounded hover:bg-green-700 shadow-sm transition-colors mr-2">核销</button>
|
|
|
|
|
|
|
| 225 |
)}
|
| 226 |
<button onClick={()=>startEdit(r)} className="text-blue-400 hover:text-blue-600 p-1" title="编辑"><Edit size={16}/></button>
|
| 227 |
+
<button onClick={()=>openDeleteConfirm(r)} className="text-gray-400 hover:text-red-500 p-1" title="撤回"><Trash2 size={16}/></button>
|
| 228 |
</>
|
| 229 |
)}
|
| 230 |
</td>
|
|
|
|
| 232 |
</tr>
|
| 233 |
);
|
| 234 |
})}
|
| 235 |
+
{displayRewards.length === 0 && <tr><td colSpan={7} className="text-center py-10 text-gray-400">暂无记录</td></tr>}
|
|
|
|
|
|
|
| 236 |
</tbody>
|
| 237 |
</table>
|
| 238 |
</div>
|
|
|
|
|
|
|
| 239 |
<div className="p-3 border-t border-gray-100 flex items-center justify-between shrink-0 bg-gray-50">
|
| 240 |
<span className="text-xs text-gray-500">共 {total} 条记录</span>
|
| 241 |
<div className="flex items-center gap-2">
|
|
|
|
| 244 |
<button onClick={()=>setPage(Math.min(totalPages, page+1))} disabled={page>=totalPages} className="p-1 rounded hover:bg-gray-200 disabled:opacity-30"><ChevronRight size={16}/></button>
|
| 245 |
</div>
|
| 246 |
</div>
|
|
|
|
|
|
|
| 247 |
{isGrantModalOpen && (
|
| 248 |
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4">
|
| 249 |
<div className="bg-white rounded-xl p-6 w-full max-w-sm animate-in fade-in zoom-in-95 shadow-2xl">
|
|
|
|
| 263 |
<button onClick={()=>setGrantForm({...grantForm, rewardType: 'ITEM'})} className={`flex-1 py-2 text-sm rounded border ${grantForm.rewardType==='ITEM' ? 'bg-blue-50 border-blue-500 text-blue-700 font-bold' : 'border-gray-200 text-gray-600'}`}>实物奖品</button>
|
| 264 |
</div>
|
| 265 |
</div>
|
|
|
|
| 266 |
{grantForm.rewardType === 'ITEM' && (
|
| 267 |
<div>
|
| 268 |
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">奖品名称</label>
|
| 269 |
<input className="w-full border border-gray-300 p-2 rounded-lg text-sm" placeholder="如: 笔记本、铅笔" value={grantForm.name} onChange={e=>setGrantForm({...grantForm, name: e.target.value})}/>
|
| 270 |
</div>
|
| 271 |
)}
|
|
|
|
| 272 |
<div>
|
| 273 |
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">发放数量</label>
|
| 274 |
<input type="number" min={1} className="w-full border border-gray-300 p-2 rounded-lg outline-none" value={grantForm.count} onChange={e=>setGrantForm({...grantForm, count: Number(e.target.value)})}/>
|
| 275 |
</div>
|
|
|
|
| 276 |
<div className="flex gap-2 pt-2">
|
| 277 |
<button onClick={() => setIsGrantModalOpen(false)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200 transition-colors">取消</button>
|
| 278 |
<button onClick={handleGrant} className="flex-1 bg-amber-500 text-white py-2 rounded-lg font-bold hover:bg-amber-600 shadow-md transition-colors">确认发放</button>
|
pages/TeacherDashboard.tsx
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useEffect, useState } from 'react';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import { Schedule, Student, Attendance } from '../types';
|
| 5 |
+
import { Calendar, UserX, AlertTriangle, Activity, Coffee, Plus, X, PaintBucket, ToggleLeft } from 'lucide-react';
|
| 6 |
+
import { TodoList } from '../components/TodoList';
|
| 7 |
+
|
| 8 |
+
const COLORS = ['#dbeafe', '#dcfce7', '#fef9c3', '#fee2e2', '#f3e8ff', '#ffedd5', '#e0e7ff', '#ecfccb'];
|
| 9 |
+
const stringToColor = (str: string) => {
|
| 10 |
+
let hash = 0;
|
| 11 |
+
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
| 12 |
+
return COLORS[Math.abs(hash) % COLORS.length];
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export const TeacherDashboard: React.FC = () => {
|
| 16 |
+
const [loading, setLoading] = useState(true);
|
| 17 |
+
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
| 18 |
+
const [students, setStudents] = useState<Student[]>([]);
|
| 19 |
+
const [attendance, setAttendance] = useState<Attendance[]>([]);
|
| 20 |
+
|
| 21 |
+
// Schedule State
|
| 22 |
+
const [showSchedule, setShowSchedule] = useState(false);
|
| 23 |
+
const [weekType, setWeekType] = useState<'ALL' | 'ODD' | 'EVEN'>('ALL'); // Currently displaying
|
| 24 |
+
const [editingCell, setEditingCell] = useState<{day: number, period: number} | null>(null);
|
| 25 |
+
const [editForm, setEditForm] = useState({ subject: '', teacherName: '', weekType: 'ALL' });
|
| 26 |
+
const [periodCount, setPeriodCount] = useState(8); // Default, could be config
|
| 27 |
+
|
| 28 |
+
const currentUser = api.auth.getCurrentUser();
|
| 29 |
+
const homeroomClass = currentUser?.homeroomClass;
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
loadData();
|
| 33 |
+
}, []);
|
| 34 |
+
|
| 35 |
+
const loadData = async () => {
|
| 36 |
+
setLoading(true);
|
| 37 |
+
try {
|
| 38 |
+
const todayStr = new Date().toISOString().split('T')[0];
|
| 39 |
+
|
| 40 |
+
const [stus, scheds, atts] = await Promise.all([
|
| 41 |
+
api.students.getAll(),
|
| 42 |
+
homeroomClass ? api.schedules.get({ className: homeroomClass }) : Promise.resolve([]),
|
| 43 |
+
homeroomClass ? api.attendance.get({ className: homeroomClass, date: todayStr }) : Promise.resolve([])
|
| 44 |
+
]);
|
| 45 |
+
|
| 46 |
+
if (homeroomClass) {
|
| 47 |
+
setStudents(stus.filter((s: Student) => s.className === homeroomClass));
|
| 48 |
+
setSchedules(scheds);
|
| 49 |
+
setAttendance(atts);
|
| 50 |
+
} else {
|
| 51 |
+
// Non-homeroom teacher view logic could be added here
|
| 52 |
+
}
|
| 53 |
+
} catch (e) { console.error(e); }
|
| 54 |
+
finally { setLoading(false); }
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
const handleSaveSchedule = async () => {
|
| 58 |
+
if (!homeroomClass || !editingCell) return;
|
| 59 |
+
if (!editForm.subject) return alert('请输入科目');
|
| 60 |
+
try {
|
| 61 |
+
await api.schedules.save({
|
| 62 |
+
className: homeroomClass,
|
| 63 |
+
dayOfWeek: editingCell.day,
|
| 64 |
+
period: editingCell.period,
|
| 65 |
+
subject: editForm.subject,
|
| 66 |
+
teacherName: editForm.teacherName,
|
| 67 |
+
weekType: editForm.weekType as any
|
| 68 |
+
});
|
| 69 |
+
setEditingCell(null);
|
| 70 |
+
// Refresh
|
| 71 |
+
const updated = await api.schedules.get({ className: homeroomClass });
|
| 72 |
+
setSchedules(updated);
|
| 73 |
+
} catch(e) { alert('保存失败'); }
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const handleDeleteSchedule = async (s: Schedule) => {
|
| 77 |
+
if(!confirm('确定删除此课程?')) return;
|
| 78 |
+
await api.schedules.delete({ className: s.className, dayOfWeek: s.dayOfWeek, period: s.period });
|
| 79 |
+
const updated = await api.schedules.get({ className: homeroomClass });
|
| 80 |
+
setSchedules(updated);
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
// Stats
|
| 84 |
+
const absentCount = attendance.filter(a => a.status === 'Absent').length;
|
| 85 |
+
const leaveCount = attendance.filter(a => a.status === 'Leave').length;
|
| 86 |
+
const presentCount = attendance.filter(a => a.status === 'Present').length;
|
| 87 |
+
const unknownCount = students.length - presentCount - leaveCount - absentCount;
|
| 88 |
+
|
| 89 |
+
const absentNames = attendance.filter(a => a.status === 'Absent').map(a => a.studentName);
|
| 90 |
+
const leaveNames = attendance.filter(a => a.status === 'Leave').map(a => a.studentName);
|
| 91 |
+
|
| 92 |
+
return (
|
| 93 |
+
<div className="space-y-6">
|
| 94 |
+
<div className="flex flex-col md:flex-row justify-between items-center bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
| 95 |
+
<div>
|
| 96 |
+
<h1 className="text-2xl font-bold text-gray-800">你好,{currentUser?.trueName || currentUser?.username} 老师</h1>
|
| 97 |
+
<p className="text-gray-500 mt-1">{homeroomClass ? `${homeroomClass} 班主任` : '科任教师'}</p>
|
| 98 |
+
</div>
|
| 99 |
+
{homeroomClass && (
|
| 100 |
+
<button onClick={() => setShowSchedule(true)} className="mt-4 md:mt-0 flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 shadow-md transition-colors">
|
| 101 |
+
<Calendar className="mr-2" size={18}/> 班级课表管理
|
| 102 |
+
</button>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 107 |
+
{/* Left Column: Stats & Todo */}
|
| 108 |
+
<div className="lg:col-span-2 space-y-6">
|
| 109 |
+
{/* Attendance Stats */}
|
| 110 |
+
{homeroomClass && (
|
| 111 |
+
<div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
|
| 112 |
+
<h3 className="font-bold text-gray-800 mb-4 flex items-center"><Activity className="mr-2 text-blue-500"/> 今日考勤概览</h3>
|
| 113 |
+
<div className="grid grid-cols-4 gap-4 text-center">
|
| 114 |
+
<div className="bg-green-50 p-3 rounded-lg border border-green-100">
|
| 115 |
+
<div className="text-2xl font-black text-green-600">{presentCount}</div>
|
| 116 |
+
<div className="text-xs text-green-700">出勤</div>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="bg-orange-50 p-3 rounded-lg border border-orange-100">
|
| 119 |
+
<div className="text-2xl font-black text-orange-500">{leaveCount}</div>
|
| 120 |
+
<div className="text-xs text-orange-700">请假</div>
|
| 121 |
+
</div>
|
| 122 |
+
<div className="bg-red-50 p-3 rounded-lg border border-red-100">
|
| 123 |
+
<div className="text-2xl font-black text-red-500">{absentCount}</div>
|
| 124 |
+
<div className="text-xs text-red-700">旷课</div>
|
| 125 |
+
</div>
|
| 126 |
+
<div className="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
| 127 |
+
<div className="text-2xl font-black text-gray-400">{unknownCount}</div>
|
| 128 |
+
<div className="text-xs text-gray-500">未打卡</div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
)}
|
| 133 |
+
|
| 134 |
+
{/* Warnings */}
|
| 135 |
+
{homeroomClass && (absentCount > 0 || leaveCount > 0) && (
|
| 136 |
+
<div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
|
| 137 |
+
<h3 className="font-bold text-gray-800 mb-4 flex items-center"><AlertTriangle className="mr-2 text-amber-500"/> 缺勤/请假名单</h3>
|
| 138 |
+
<div className="space-y-3">
|
| 139 |
+
{absentCount > 0 && (
|
| 140 |
+
<div className="flex gap-2 items-start bg-red-50 p-3 rounded-lg border border-red-100">
|
| 141 |
+
<div className="font-bold text-red-600 text-sm whitespace-nowrap">旷课人员:</div>
|
| 142 |
+
<div className="text-sm text-red-800 flex flex-wrap gap-2">
|
| 143 |
+
{absentNames.map(n => <span key={n} className="bg-white px-2 py-0.5 rounded border border-red-200">{n}</span>)}
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
)}
|
| 147 |
+
{leaveCount > 0 && (
|
| 148 |
+
<div className="flex gap-2 items-start bg-orange-50 p-3 rounded-lg border border-orange-100">
|
| 149 |
+
<div className="font-bold text-orange-600 text-sm whitespace-nowrap">请假人员:</div>
|
| 150 |
+
<div className="text-sm text-orange-800 flex flex-wrap gap-2">
|
| 151 |
+
{leaveNames.map(n => <span key={n} className="bg-white px-2 py-0.5 rounded border border-orange-200">{n}</span>)}
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
)}
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
{/* Right Column: Todo List */}
|
| 161 |
+
<div className="h-[500px]">
|
| 162 |
+
<TodoList />
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* Schedule Modal */}
|
| 167 |
+
{showSchedule && homeroomClass && (
|
| 168 |
+
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
| 169 |
+
<div className="bg-white rounded-xl w-full max-w-6xl h-[90vh] flex flex-col shadow-2xl animate-in zoom-in-95">
|
| 170 |
+
<div className="p-4 border-b flex justify-between items-center">
|
| 171 |
+
<div className="flex items-center gap-4">
|
| 172 |
+
<h3 className="text-xl font-bold flex items-center"><Calendar className="mr-2 text-blue-600"/> 智能课表 ({homeroomClass})</h3>
|
| 173 |
+
<div className="flex bg-gray-100 p-1 rounded-lg text-xs font-bold">
|
| 174 |
+
{['ALL','ODD','EVEN'].map(t => (
|
| 175 |
+
<button
|
| 176 |
+
key={t}
|
| 177 |
+
onClick={() => setWeekType(t as any)}
|
| 178 |
+
className={`px-3 py-1.5 rounded transition-all ${weekType === t ? 'bg-white shadow text-blue-600' : 'text-gray-500'}`}
|
| 179 |
+
>
|
| 180 |
+
{t === 'ALL' ? '全周' : t === 'ODD' ? '单周' : '双周'}
|
| 181 |
+
</button>
|
| 182 |
+
))}
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
<button onClick={() => setShowSchedule(false)}><X size={24} className="text-gray-400 hover:text-gray-600"/></button>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<div className="flex-1 overflow-auto bg-gray-50 p-4">
|
| 189 |
+
<table className="w-full border-collapse bg-white shadow-sm rounded-lg overflow-hidden">
|
| 190 |
+
<thead>
|
| 191 |
+
<tr className="bg-gray-100 text-gray-500 text-sm">
|
| 192 |
+
<th className="p-3 border w-20">节次</th>
|
| 193 |
+
{['周一','周二','周三','周四','周五'].map(d=><th key={d} className="p-3 border w-[18%]">{d}</th>)}
|
| 194 |
+
</tr>
|
| 195 |
+
</thead>
|
| 196 |
+
<tbody>
|
| 197 |
+
{Array.from({length: periodCount}).map((_, idx) => {
|
| 198 |
+
const period = idx + 1;
|
| 199 |
+
return (
|
| 200 |
+
<tr key={period}>
|
| 201 |
+
<td className="border p-3 text-center text-sm font-bold text-gray-600 bg-gray-50">第{period}节</td>
|
| 202 |
+
{[1,2,3,4,5].map(day => {
|
| 203 |
+
// Filter relevant schedules based on week type
|
| 204 |
+
const slotItems = schedules.filter(s =>
|
| 205 |
+
s.dayOfWeek === day &&
|
| 206 |
+
s.period === period &&
|
| 207 |
+
(s.weekType === 'ALL' || s.weekType === weekType || weekType === 'ALL')
|
| 208 |
+
);
|
| 209 |
+
|
| 210 |
+
return (
|
| 211 |
+
<td key={day} className="border p-1 align-top h-24 relative group hover:bg-blue-50 transition-colors" onClick={() => {
|
| 212 |
+
setEditingCell({day, period});
|
| 213 |
+
setEditForm({subject:'', teacherName:'', weekType: weekType === 'ALL' ? 'ALL' : weekType});
|
| 214 |
+
}}>
|
| 215 |
+
<div className="flex flex-col gap-1 h-full">
|
| 216 |
+
{slotItems.map(item => (
|
| 217 |
+
<div
|
| 218 |
+
key={item._id}
|
| 219 |
+
className="p-1 rounded text-xs border relative group/item"
|
| 220 |
+
style={{backgroundColor: stringToColor(item.subject), borderColor: 'rgba(0,0,0,0.1)'}}
|
| 221 |
+
onClick={(e) => { e.stopPropagation(); }}
|
| 222 |
+
>
|
| 223 |
+
<div className="font-bold text-gray-800">{item.subject}</div>
|
| 224 |
+
<div className="flex justify-between items-center text-[10px] text-gray-600 mt-0.5">
|
| 225 |
+
<span>{item.teacherName}</span>
|
| 226 |
+
{item.weekType !== 'ALL' && <span className="bg-white/50 px-1 rounded">{item.weekType==='ODD'?'单':'双'}</span>}
|
| 227 |
+
</div>
|
| 228 |
+
<button
|
| 229 |
+
onClick={(e) => { e.stopPropagation(); handleDeleteSchedule(item); }}
|
| 230 |
+
className="absolute top-0 right-0 p-0.5 text-red-500 opacity-0 group-hover/item:opacity-100"
|
| 231 |
+
>
|
| 232 |
+
<X size={12}/>
|
| 233 |
+
</button>
|
| 234 |
+
</div>
|
| 235 |
+
))}
|
| 236 |
+
{slotItems.length === 0 && <div className="h-full flex items-center justify-center opacity-0 group-hover:opacity-50"><Plus size={20} className="text-blue-300"/></div>}
|
| 237 |
+
</div>
|
| 238 |
+
</td>
|
| 239 |
+
);
|
| 240 |
+
})}
|
| 241 |
+
</tr>
|
| 242 |
+
);
|
| 243 |
+
})}
|
| 244 |
+
</tbody>
|
| 245 |
+
</table>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
)}
|
| 250 |
+
|
| 251 |
+
{/* Edit Modal */}
|
| 252 |
+
{editingCell && (
|
| 253 |
+
<div className="fixed inset-0 bg-black/20 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
|
| 254 |
+
<div className="bg-white p-6 rounded-xl shadow-2xl w-80 animate-in zoom-in-95">
|
| 255 |
+
<h4 className="font-bold mb-4 text-gray-800">编辑课程</h4>
|
| 256 |
+
<div className="space-y-3">
|
| 257 |
+
<div>
|
| 258 |
+
<label className="text-xs font-bold text-gray-500">科目</label>
|
| 259 |
+
<input className="w-full border rounded p-2 text-sm" value={editForm.subject} onChange={e=>setEditForm({...editForm, subject: e.target.value})} autoFocus/>
|
| 260 |
+
</div>
|
| 261 |
+
<div>
|
| 262 |
+
<label className="text-xs font-bold text-gray-500">教师 (可选)</label>
|
| 263 |
+
<input className="w-full border rounded p-2 text-sm" value={editForm.teacherName} onChange={e=>setEditForm({...editForm, teacherName: e.target.value})}/>
|
| 264 |
+
</div>
|
| 265 |
+
<div>
|
| 266 |
+
<label className="text-xs font-bold text-gray-500">周次类型</label>
|
| 267 |
+
<select className="w-full border rounded p-2 text-sm" value={editForm.weekType} onChange={e=>setEditForm({...editForm, weekType: e.target.value})}>
|
| 268 |
+
<option value="ALL">每周</option>
|
| 269 |
+
<option value="ODD">单周</option>
|
| 270 |
+
<option value="EVEN">双周</option>
|
| 271 |
+
</select>
|
| 272 |
+
</div>
|
| 273 |
+
<div className="flex gap-2 pt-2">
|
| 274 |
+
<button onClick={handleSaveSchedule} className="flex-1 bg-blue-600 text-white py-2 rounded font-bold">保存</button>
|
| 275 |
+
<button onClick={()=>setEditingCell(null)} className="flex-1 bg-gray-100 text-gray-700 py-2 rounded">取消</button>
|
| 276 |
+
</div>
|
| 277 |
+
</div>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
)}
|
| 281 |
+
</div>
|
| 282 |
+
);
|
| 283 |
+
};
|
pages/WishesAndFeedback.tsx
CHANGED
|
@@ -3,27 +3,18 @@ import React, { useState, useEffect } from 'react';
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Student, User, Wish, Feedback } from '../types';
|
| 5 |
import { Loader2, Send, Heart, MessageSquare, Check, X, Filter, Trash2, Shuffle, CheckCircle, Mail } from 'lucide-react';
|
| 6 |
-
import {
|
| 7 |
|
| 8 |
-
// --- SUB-COMPONENTS ---
|
| 9 |
-
|
| 10 |
-
// 1. Wish Note Card
|
| 11 |
const WishNote = ({ wish, onFulfill }: { wish: Wish, onFulfill?: (id: string) => void }) => {
|
| 12 |
return (
|
| 13 |
<div className={`relative p-4 w-40 h-40 md:w-48 md:h-48 shadow-lg flex flex-col justify-between transition-transform hover:scale-105 hover:z-10 rotate-${Math.floor(Math.random()*6)-3} ${wish.status === 'FULFILLED' ? 'bg-gray-100 grayscale opacity-70' : 'bg-yellow-100'}`} style={{fontFamily: '"Comic Sans MS", cursive, sans-serif'}}>
|
| 14 |
-
{/* Pin */}
|
| 15 |
<div className="absolute -top-3 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-red-500 shadow-sm border-2 border-red-700 z-20"></div>
|
| 16 |
-
|
| 17 |
<div className="text-xs text-gray-500 font-bold mb-1">{new Date(wish.createTime).toLocaleDateString()}</div>
|
| 18 |
-
<div className="flex-1 overflow-hidden text-sm text-gray-800 leading-relaxed font-medium break-words">
|
| 19 |
-
{wish.content}
|
| 20 |
-
</div>
|
| 21 |
<div className="mt-2 pt-2 border-t border-black/10 flex justify-between items-end">
|
| 22 |
<div className="text-xs font-bold text-gray-600 truncate max-w-[80px]">{wish.studentName}</div>
|
| 23 |
{onFulfill && wish.status === 'PENDING' && (
|
| 24 |
-
<button onClick={() => onFulfill(wish._id!)} className="bg-green-500 text-white p-1 rounded-full hover:bg-green-600 shadow-sm" title="实现愿望">
|
| 25 |
-
<Check size={14}/>
|
| 26 |
-
</button>
|
| 27 |
)}
|
| 28 |
{wish.status === 'FULFILLED' && <CheckCircle size={16} className="text-green-600"/>}
|
| 29 |
</div>
|
|
@@ -31,79 +22,20 @@ const WishNote = ({ wish, onFulfill }: { wish: Wish, onFulfill?: (id: string) =>
|
|
| 31 |
);
|
| 32 |
};
|
| 33 |
|
| 34 |
-
// 2. Feedback Item
|
| 35 |
const FeedbackItem = ({ fb, onStatusChange }: { fb: Feedback, onStatusChange?: (id: string, status: string, reply?: string) => void }) => {
|
| 36 |
const [replyText, setReplyText] = useState(fb.reply || '');
|
| 37 |
const [isReplying, setIsReplying] = useState(false);
|
| 38 |
-
|
| 39 |
-
const handleAction = (status: string) => {
|
| 40 |
-
if (onStatusChange) {
|
| 41 |
-
onStatusChange(fb._id!, status, isReplying ? replyText : undefined);
|
| 42 |
-
setIsReplying(false);
|
| 43 |
-
}
|
| 44 |
-
};
|
| 45 |
-
|
| 46 |
return (
|
| 47 |
<div className="bg-white p-4 rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow">
|
| 48 |
<div className="flex justify-between items-start mb-2">
|
| 49 |
-
<div className="flex items-center gap-2">
|
| 50 |
-
|
| 51 |
-
{fb.creatorName[0]}
|
| 52 |
-
</div>
|
| 53 |
-
<div>
|
| 54 |
-
<div className="font-bold text-sm text-gray-800">{fb.creatorName} <span className="text-xs font-normal text-gray-400">({fb.creatorRole === 'STUDENT' ? '学生' : '老师'})</span></div>
|
| 55 |
-
<div className="text-[10px] text-gray-400">{new Date(fb.createTime).toLocaleString()}</div>
|
| 56 |
-
</div>
|
| 57 |
-
</div>
|
| 58 |
-
<div className={`px-2 py-1 rounded text-xs font-bold ${
|
| 59 |
-
fb.status === 'PENDING' ? 'bg-gray-100 text-gray-500' :
|
| 60 |
-
fb.status === 'ACCEPTED' ? 'bg-blue-100 text-blue-600' :
|
| 61 |
-
fb.status === 'PROCESSED' ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'
|
| 62 |
-
}`}>
|
| 63 |
-
{fb.status === 'PENDING' ? '待处理' : fb.status === 'ACCEPTED' ? '已接受' : fb.status === 'PROCESSED' ? '已处理' : '已忽略'}
|
| 64 |
-
</div>
|
| 65 |
-
</div>
|
| 66 |
-
|
| 67 |
-
<div className="bg-gray-50 p-3 rounded-lg text-sm text-gray-700 mb-3 whitespace-pre-wrap border border-gray-100">
|
| 68 |
-
{fb.content}
|
| 69 |
</div>
|
| 70 |
-
|
| 71 |
-
{fb.reply && !isReplying &&
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
</div>
|
| 75 |
-
)}
|
| 76 |
-
|
| 77 |
-
{isReplying && (
|
| 78 |
-
<div className="mb-3">
|
| 79 |
-
<textarea
|
| 80 |
-
className="w-full border rounded p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
| 81 |
-
placeholder="请输入回复内容..."
|
| 82 |
-
value={replyText}
|
| 83 |
-
onChange={e => setReplyText(e.target.value)}
|
| 84 |
-
/>
|
| 85 |
-
</div>
|
| 86 |
-
)}
|
| 87 |
-
|
| 88 |
-
{onStatusChange && (
|
| 89 |
-
<div className="flex gap-2 justify-end">
|
| 90 |
-
{fb.status === 'PENDING' && (
|
| 91 |
-
<>
|
| 92 |
-
<button onClick={() => handleAction('IGNORED')} className="text-xs text-gray-400 hover:text-red-500 px-2 py-1">忽略</button>
|
| 93 |
-
<button onClick={() => handleAction('ACCEPTED')} className="text-xs bg-blue-100 text-blue-600 px-3 py-1 rounded hover:bg-blue-200 font-bold">接受</button>
|
| 94 |
-
<button onClick={() => { setIsReplying(true); if(isReplying && replyText) handleAction('PROCESSED'); }} className="text-xs bg-green-100 text-green-600 px-3 py-1 rounded hover:bg-green-200 font-bold">
|
| 95 |
-
{isReplying ? '提交回复并完成' : '回复并处理'}
|
| 96 |
-
</button>
|
| 97 |
-
</>
|
| 98 |
-
)}
|
| 99 |
-
{/* Allow replying to accepted feedback */}
|
| 100 |
-
{fb.status === 'ACCEPTED' && (
|
| 101 |
-
<button onClick={() => { setIsReplying(true); if(isReplying && replyText) handleAction('PROCESSED'); }} className="text-xs bg-green-100 text-green-600 px-3 py-1 rounded hover:bg-green-200 font-bold">
|
| 102 |
-
{isReplying ? '提交回复并完成' : '处理'}
|
| 103 |
-
</button>
|
| 104 |
-
)}
|
| 105 |
-
</div>
|
| 106 |
-
)}
|
| 107 |
</div>
|
| 108 |
);
|
| 109 |
};
|
|
@@ -113,369 +45,141 @@ export const WishesAndFeedback: React.FC = () => {
|
|
| 113 |
const [loading, setLoading] = useState(true);
|
| 114 |
const [wishes, setWishes] = useState<Wish[]>([]);
|
| 115 |
const [feedbackList, setFeedbackList] = useState<Feedback[]>([]);
|
| 116 |
-
|
| 117 |
-
// Config / Data
|
| 118 |
const [teachers, setTeachers] = useState<User[]>([]);
|
| 119 |
const [studentInfo, setStudentInfo] = useState<Student | null>(null);
|
| 120 |
const [myPendingWish, setMyPendingWish] = useState<Wish | null>(null);
|
| 121 |
-
|
| 122 |
-
// Filter States
|
| 123 |
-
const [filterStatus, setFilterStatus] = useState<string>('PENDING,ACCEPTED,PROCESSED'); // Default hide ignored
|
| 124 |
-
|
| 125 |
-
// Forms
|
| 126 |
const [wishContent, setWishContent] = useState('');
|
| 127 |
const [feedbackContent, setFeedbackContent] = useState('');
|
| 128 |
const [selectedTeacherId, setSelectedTeacherId] = useState('');
|
|
|
|
| 129 |
|
| 130 |
const currentUser = api.auth.getCurrentUser();
|
| 131 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 132 |
const isTeacher = currentUser?.role === 'TEACHER';
|
| 133 |
-
const isPrincipal = currentUser?.role === 'PRINCIPAL';
|
| 134 |
const isAdmin = currentUser?.role === 'ADMIN';
|
| 135 |
|
| 136 |
-
useEffect(() => {
|
| 137 |
-
loadData();
|
| 138 |
-
}, [activeTab, filterStatus]);
|
| 139 |
|
| 140 |
const loadData = async () => {
|
| 141 |
setLoading(true);
|
| 142 |
try {
|
| 143 |
-
// Common: Load Teachers for selection
|
| 144 |
if (teachers.length === 0 && !isAdmin) {
|
| 145 |
-
// If student, get class teachers. If teacher, get colleagues or just use list for principal
|
| 146 |
if (isStudent) {
|
| 147 |
const stus = await api.students.getAll();
|
| 148 |
const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
|
| 149 |
-
if (me) {
|
| 150 |
-
|
| 151 |
-
const tList = await api.users.getTeachersForClass(me.className);
|
| 152 |
-
setTeachers(tList);
|
| 153 |
-
// Default teacher is homeroom if possible, or first in list
|
| 154 |
-
// Logic for finding homeroom teacher ID is complex on frontend without extra calls,
|
| 155 |
-
// so we might just default to first one.
|
| 156 |
-
if (tList.length > 0) setSelectedTeacherId(tList[0]._id!);
|
| 157 |
-
}
|
| 158 |
-
} else {
|
| 159 |
-
const allTeachers = await api.users.getAll({ role: 'TEACHER' });
|
| 160 |
-
setTeachers(allTeachers);
|
| 161 |
-
}
|
| 162 |
}
|
| 163 |
-
|
| 164 |
if (activeTab === 'wishes') {
|
| 165 |
-
if (isStudent && studentInfo) {
|
| 166 |
-
// Load MY wishes
|
| 167 |
-
const myWishes = await api.wishes.getAll({ studentId: studentInfo._id || String(studentInfo.id) });
|
| 168 |
-
setWishes(myWishes);
|
| 169 |
-
setMyPendingWish(myWishes.find((w: Wish) => w.status === 'PENDING') || null);
|
| 170 |
-
} else if (isTeacher) {
|
| 171 |
-
// Load wishes FOR ME
|
| 172 |
-
const myWishes = await api.wishes.getAll({ teacherId: currentUser?._id });
|
| 173 |
-
setWishes(myWishes);
|
| 174 |
-
}
|
| 175 |
} else if (activeTab === 'feedback') {
|
| 176 |
-
if (isStudent && studentInfo) {
|
| 177 |
-
const myFb = await api.feedback.getAll({ creatorId: studentInfo._id || String(studentInfo.id), type: 'ACADEMIC' });
|
| 178 |
-
setFeedbackList(myFb);
|
| 179 |
-
} else if (isTeacher) {
|
| 180 |
-
const fbForMe = await api.feedback.getAll({ targetId: currentUser?._id, type: 'ACADEMIC', status: filterStatus });
|
| 181 |
-
setFeedbackList(fbForMe);
|
| 182 |
-
}
|
| 183 |
} else if (activeTab === 'system') {
|
| 184 |
-
if (isAdmin) {
|
| 185 |
-
const sysFb = await api.feedback.getAll({ type: 'SYSTEM' });
|
| 186 |
-
setFeedbackList(sysFb);
|
| 187 |
-
} else {
|
| 188 |
-
// Teacher/Principal view their own system feedback
|
| 189 |
-
const mySysFb = await api.feedback.getAll({ creatorId: currentUser?._id, type: 'SYSTEM' });
|
| 190 |
-
setFeedbackList(mySysFb);
|
| 191 |
-
}
|
| 192 |
}
|
| 193 |
-
|
| 194 |
-
} catch (e) { console.error(e); }
|
| 195 |
-
finally { setLoading(false); }
|
| 196 |
};
|
| 197 |
|
| 198 |
-
// --- Actions ---
|
| 199 |
-
|
| 200 |
const submitWish = async () => {
|
| 201 |
if (!wishContent.trim()) return alert('请输入愿望内容');
|
| 202 |
if (!selectedTeacherId) return alert('请选择许愿对象');
|
| 203 |
-
|
| 204 |
try {
|
| 205 |
const targetTeacher = teachers.find(t => t._id === selectedTeacherId);
|
| 206 |
-
await api.wishes.create({
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
className: studentInfo?.className,
|
| 210 |
-
teacherId: selectedTeacherId,
|
| 211 |
-
teacherName: targetTeacher?.trueName || targetTeacher?.username,
|
| 212 |
-
content: wishContent,
|
| 213 |
-
status: 'PENDING'
|
| 214 |
-
});
|
| 215 |
-
alert('许愿成功!愿望已挂上许愿树。');
|
| 216 |
-
setWishContent('');
|
| 217 |
-
loadData();
|
| 218 |
-
} catch (e: any) {
|
| 219 |
-
alert(e.message || '许愿失败');
|
| 220 |
-
}
|
| 221 |
};
|
| 222 |
|
| 223 |
const fulfillWish = async (id: string) => {
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
};
|
| 228 |
|
| 229 |
const randomFulfill = async () => {
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
};
|
| 237 |
|
| 238 |
const submitFeedback = async (type: 'ACADEMIC' | 'SYSTEM') => {
|
| 239 |
-
const content = type === 'ACADEMIC' ? feedbackContent : wishContent;
|
| 240 |
if (!content.trim()) return alert('请输入内容');
|
| 241 |
-
|
| 242 |
try {
|
| 243 |
-
const payload: Partial<Feedback> = {
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
creatorRole: currentUser?.role,
|
| 247 |
-
content: content,
|
| 248 |
-
type: type,
|
| 249 |
-
status: 'PENDING'
|
| 250 |
-
};
|
| 251 |
-
|
| 252 |
-
if (type === 'ACADEMIC') {
|
| 253 |
-
if (!selectedTeacherId) return alert('请选择反馈对象');
|
| 254 |
-
const targetTeacher = teachers.find(t => t._id === selectedTeacherId);
|
| 255 |
-
payload.targetId = selectedTeacherId;
|
| 256 |
-
payload.targetName = targetTeacher?.trueName || targetTeacher?.username;
|
| 257 |
-
} else {
|
| 258 |
-
payload.targetId = 'ADMIN';
|
| 259 |
-
payload.targetName = '系统管理员';
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
await api.feedback.create(payload);
|
| 263 |
-
alert('提交成功');
|
| 264 |
-
if (type === 'ACADEMIC') setFeedbackContent(''); else setWishContent('');
|
| 265 |
-
loadData();
|
| 266 |
} catch(e) { alert('提交失败'); }
|
| 267 |
};
|
| 268 |
|
| 269 |
-
const handleFeedbackStatus = async (id: string, status: string, reply?: string) => {
|
| 270 |
-
await api.feedback.update(id, { status, reply });
|
| 271 |
-
loadData();
|
| 272 |
-
};
|
| 273 |
-
|
| 274 |
const handleIgnoreAll = async () => {
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
};
|
| 279 |
|
| 280 |
-
// --- RENDER ---
|
| 281 |
-
|
| 282 |
return (
|
| 283 |
<div className="h-full flex flex-col bg-slate-50 overflow-hidden">
|
| 284 |
-
{
|
| 285 |
<div className="bg-white border-b border-gray-200 px-6 pt-4 flex gap-6 shrink-0 shadow-sm z-10">
|
| 286 |
-
<button onClick={() => setActiveTab('wishes')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'wishes' ? 'border-pink-500 text-pink-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 287 |
-
|
| 288 |
-
</button>
|
| 289 |
-
{/* Feedback Tab logic: Students see "My Feedback", Teachers see "Inbox" */}
|
| 290 |
-
<button onClick={() => setActiveTab('feedback')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'feedback' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 291 |
-
<MessageSquare size={18} className={activeTab === 'feedback' ? 'fill-blue-500' : ''}/> {isStudent ? '我的意见箱' : '学生反馈'}
|
| 292 |
-
</button>
|
| 293 |
-
{/* System Feedback: Admin sees inbox, others see submission form */}
|
| 294 |
-
{!isStudent && (
|
| 295 |
-
<button onClick={() => setActiveTab('system')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'system' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
| 296 |
-
<Mail size={18} className={activeTab === 'system' ? 'fill-purple-500' : ''}/> 系统建议
|
| 297 |
-
</button>
|
| 298 |
-
)}
|
| 299 |
</div>
|
| 300 |
-
|
| 301 |
<div className="flex-1 overflow-hidden relative">
|
| 302 |
{loading && <div className="absolute inset-0 bg-white/50 z-50 flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>}
|
| 303 |
-
|
| 304 |
-
{/* --- TAB: WISHES --- */}
|
| 305 |
{activeTab === 'wishes' && (
|
| 306 |
<div className="h-full flex flex-col">
|
| 307 |
-
{/* Tree Background Area */}
|
| 308 |
<div className="flex-1 bg-green-50 overflow-y-auto p-6 relative custom-scrollbar">
|
| 309 |
-
<div className="absolute inset-0 opacity-10 pointer-events-none flex justify-center items-center">
|
| 310 |
-
{/* Simple CSS Tree representation or SVG */}
|
| 311 |
-
<svg viewBox="0 0 200 200" className="w-full h-full text-green-800" fill="currentColor">
|
| 312 |
-
<path d="M100 20 L140 100 H120 L150 160 H50 L80 100 H60 Z" />
|
| 313 |
-
<rect x="90" y="160" width="20" height="40" />
|
| 314 |
-
</svg>
|
| 315 |
-
</div>
|
| 316 |
-
|
| 317 |
<div className="relative z-10 flex flex-wrap gap-6 justify-center content-start min-h-[300px]">
|
| 318 |
-
{wishes.length === 0 ? (
|
| 319 |
-
<div className="text-gray-400 mt-20 font-bold bg-white/80 p-4 rounded-xl shadow-sm">
|
| 320 |
-
🌲 许愿树上还空荡荡的,快来挂上第一个愿望吧!
|
| 321 |
-
</div>
|
| 322 |
-
) : (
|
| 323 |
-
wishes.map(w => (
|
| 324 |
-
<WishNote key={w._id} wish={w} onFulfill={isTeacher ? fulfillWish : undefined} />
|
| 325 |
-
))
|
| 326 |
-
)}
|
| 327 |
</div>
|
| 328 |
</div>
|
| 329 |
-
|
| 330 |
-
{/* Bottom Control Panel */}
|
| 331 |
<div className="bg-white border-t border-gray-200 p-4 shrink-0 shadow-[0_-4px_10px_-1px_rgba(0,0,0,0.05)]">
|
| 332 |
-
{isStudent ? (
|
| 333 |
-
myPendingWish ? (
|
| 334 |
-
<div className="text-center p-4 bg-yellow-50 border border-yellow-200 rounded-xl">
|
| 335 |
-
<p className="text-yellow-800 font-bold mb-1">您许下的愿望正在等待实现中...</p>
|
| 336 |
-
<p className="text-sm text-yellow-600">"{myPendingWish.content}"</p>
|
| 337 |
-
<p className="text-xs text-gray-400 mt-2">当老师实现此愿望后,您才可以许下新的愿望。</p>
|
| 338 |
-
</div>
|
| 339 |
-
) : (
|
| 340 |
-
<div className="flex flex-col md:flex-row gap-4 items-end max-w-4xl mx-auto">
|
| 341 |
-
<div className="flex-1 w-full">
|
| 342 |
-
<label className="text-xs font-bold text-gray-500 mb-1 block uppercase">许愿内容</label>
|
| 343 |
-
<input
|
| 344 |
-
className="w-full border border-gray-300 rounded-lg p-3 focus:ring-2 focus:ring-pink-500 outline-none"
|
| 345 |
-
placeholder="我希望..."
|
| 346 |
-
value={wishContent}
|
| 347 |
-
onChange={e => setWishContent(e.target.value)}
|
| 348 |
-
/>
|
| 349 |
-
</div>
|
| 350 |
-
<div className="w-full md:w-48">
|
| 351 |
-
<label className="text-xs font-bold text-gray-500 mb-1 block uppercase">许愿对象</label>
|
| 352 |
-
<select
|
| 353 |
-
className="w-full border border-gray-300 rounded-lg p-3 bg-white outline-none"
|
| 354 |
-
value={selectedTeacherId}
|
| 355 |
-
onChange={e => setSelectedTeacherId(e.target.value)}
|
| 356 |
-
>
|
| 357 |
-
{teachers.map(t => <option key={t._id} value={t._id}>{t.teachingSubject ? `${t.teachingSubject}-${t.trueName||t.username}` : t.trueName||t.username}</option>)}
|
| 358 |
-
</select>
|
| 359 |
-
</div>
|
| 360 |
-
<button onClick={submitWish} className="w-full md:w-auto bg-pink-500 text-white px-6 py-3 rounded-lg font-bold hover:bg-pink-600 flex items-center justify-center gap-2 shadow-md">
|
| 361 |
-
<Send size={18}/> 挂上愿望
|
| 362 |
-
</button>
|
| 363 |
-
</div>
|
| 364 |
-
)
|
| 365 |
-
) : isTeacher ? (
|
| 366 |
-
<div className="flex justify-between items-center max-w-4xl mx-auto">
|
| 367 |
-
<div className="text-sm text-gray-500">
|
| 368 |
-
<span className="font-bold text-gray-800">{wishes.filter(w=>w.status==='PENDING').length}</span> 个待实现愿望
|
| 369 |
-
</div>
|
| 370 |
-
<button onClick={randomFulfill} className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-6 py-3 rounded-lg font-bold hover:shadow-lg transition-all flex items-center gap-2">
|
| 371 |
-
<Shuffle size={18}/> 随机实现一个愿望
|
| 372 |
-
</button>
|
| 373 |
-
</div>
|
| 374 |
-
) : <div className="text-center text-gray-400">只读模式</div>}
|
| 375 |
</div>
|
| 376 |
</div>
|
| 377 |
)}
|
| 378 |
-
|
| 379 |
-
{/* --- TAB: FEEDBACK --- */}
|
| 380 |
{activeTab === 'feedback' && (
|
| 381 |
<div className="h-full flex flex-col md:flex-row">
|
| 382 |
-
{/* List Area */}
|
| 383 |
<div className="flex-1 overflow-y-auto p-6 bg-gray-50">
|
| 384 |
-
{isTeacher && (
|
| 385 |
-
|
| 386 |
-
<div className="flex items-center gap-2 text-sm text-gray-600">
|
| 387 |
-
<Filter size={16}/>
|
| 388 |
-
<label className="flex items-center gap-1 cursor-pointer">
|
| 389 |
-
<input type="checkbox" checked={filterStatus.includes('IGNORED')} onChange={e => {
|
| 390 |
-
if(e.target.checked) setFilterStatus('PENDING,ACCEPTED,PROCESSED,IGNORED');
|
| 391 |
-
else setFilterStatus('PENDING,ACCEPTED,PROCESSED');
|
| 392 |
-
}} />
|
| 393 |
-
显示已忽略
|
| 394 |
-
</label>
|
| 395 |
-
</div>
|
| 396 |
-
<button onClick={handleIgnoreAll} className="text-xs text-red-500 hover:text-red-600 hover:bg-red-50 px-3 py-1.5 rounded flex items-center transition-colors">
|
| 397 |
-
<Trash2 size={14} className="mr-1"/> 一键忽略所有待处理
|
| 398 |
-
</button>
|
| 399 |
-
</div>
|
| 400 |
-
)}
|
| 401 |
-
|
| 402 |
-
<div className="space-y-4 max-w-3xl mx-auto">
|
| 403 |
-
{feedbackList.length === 0 ? (
|
| 404 |
-
<div className="text-center text-gray-400 py-10">暂无反馈记录</div>
|
| 405 |
-
) : (
|
| 406 |
-
feedbackList.map(fb => (
|
| 407 |
-
<FeedbackItem key={fb._id} fb={fb} onStatusChange={isTeacher ? handleFeedbackStatus : undefined} />
|
| 408 |
-
))
|
| 409 |
-
)}
|
| 410 |
-
</div>
|
| 411 |
</div>
|
| 412 |
-
|
| 413 |
-
{/* Input Area (Student Only) */}
|
| 414 |
-
{isStudent && (
|
| 415 |
-
<div className="w-full md:w-80 bg-white border-l border-gray-200 p-6 flex flex-col shadow-xl z-20">
|
| 416 |
-
<h3 className="font-bold text-gray-800 mb-4 flex items-center"><MessageSquare size={18} className="mr-2 text-blue-500"/> 提交反馈</h3>
|
| 417 |
-
<div className="space-y-4 flex-1">
|
| 418 |
-
<div>
|
| 419 |
-
<label className="text-xs font-bold text-gray-500 mb-1 block uppercase">反馈对象</label>
|
| 420 |
-
<select
|
| 421 |
-
className="w-full border border-gray-300 rounded-lg p-2 text-sm bg-white"
|
| 422 |
-
value={selectedTeacherId}
|
| 423 |
-
onChange={e => setSelectedTeacherId(e.target.value)}
|
| 424 |
-
>
|
| 425 |
-
{teachers.map(t => <option key={t._id} value={t._id}>{t.teachingSubject ? `${t.teachingSubject}-${t.trueName||t.username}` : t.trueName||t.username}</option>)}
|
| 426 |
-
</select>
|
| 427 |
-
</div>
|
| 428 |
-
<div className="flex-1 flex flex-col">
|
| 429 |
-
<label className="text-xs font-bold text-gray-500 mb-1 block uppercase">内容</label>
|
| 430 |
-
<textarea
|
| 431 |
-
className="w-full border border-gray-300 rounded-lg p-3 text-sm flex-1 resize-none focus:ring-2 focus:ring-blue-500 outline-none"
|
| 432 |
-
placeholder="老师,我对课程有建议..."
|
| 433 |
-
value={feedbackContent}
|
| 434 |
-
onChange={e => setFeedbackContent(e.target.value)}
|
| 435 |
-
/>
|
| 436 |
-
</div>
|
| 437 |
-
<button onClick={() => submitFeedback('ACADEMIC')} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 shadow-md">
|
| 438 |
-
提交反馈
|
| 439 |
-
</button>
|
| 440 |
-
</div>
|
| 441 |
-
</div>
|
| 442 |
-
)}
|
| 443 |
</div>
|
| 444 |
)}
|
| 445 |
-
|
| 446 |
-
{/* --- TAB: SYSTEM FEEDBACK --- */}
|
| 447 |
{activeTab === 'system' && (
|
| 448 |
<div className="h-full flex flex-col md:flex-row">
|
| 449 |
-
<div className="flex-1 overflow-y-auto p-6 bg-gray-50">
|
| 450 |
-
|
| 451 |
-
{feedbackList.length === 0 ? (
|
| 452 |
-
<div className="text-center text-gray-400 py-10">暂无系统反馈</div>
|
| 453 |
-
) : (
|
| 454 |
-
feedbackList.map(fb => (
|
| 455 |
-
<FeedbackItem key={fb._id} fb={fb} onStatusChange={isAdmin ? handleFeedbackStatus : undefined} />
|
| 456 |
-
))
|
| 457 |
-
)}
|
| 458 |
-
</div>
|
| 459 |
-
</div>
|
| 460 |
-
|
| 461 |
-
{!isAdmin && (
|
| 462 |
-
<div className="w-full md:w-80 bg-white border-l border-gray-200 p-6 flex flex-col shadow-xl z-20">
|
| 463 |
-
<h3 className="font-bold text-gray-800 mb-4 flex items-center"><Mail size={18} className="mr-2 text-purple-500"/> 联系管理员</h3>
|
| 464 |
-
<p className="text-xs text-gray-500 mb-4">如果您在使用系统中遇到问题或有优化建议,请在此反馈。</p>
|
| 465 |
-
<textarea
|
| 466 |
-
className="w-full border border-gray-300 rounded-lg p-3 text-sm h-40 resize-none focus:ring-2 focus:ring-purple-500 outline-none mb-4"
|
| 467 |
-
placeholder="描述您的问题或建议..."
|
| 468 |
-
value={wishContent} // Reusing state var for simplicity
|
| 469 |
-
onChange={e => setWishContent(e.target.value)}
|
| 470 |
-
/>
|
| 471 |
-
<button onClick={() => submitFeedback('SYSTEM')} className="w-full bg-purple-600 text-white py-3 rounded-lg font-bold hover:bg-purple-700 shadow-md">
|
| 472 |
-
发送反馈
|
| 473 |
-
</button>
|
| 474 |
-
</div>
|
| 475 |
-
)}
|
| 476 |
</div>
|
| 477 |
)}
|
| 478 |
</div>
|
| 479 |
</div>
|
| 480 |
);
|
| 481 |
-
};
|
|
|
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { Student, User, Wish, Feedback } from '../types';
|
| 5 |
import { Loader2, Send, Heart, MessageSquare, Check, X, Filter, Trash2, Shuffle, CheckCircle, Mail } from 'lucide-react';
|
| 6 |
+
import { ConfirmModal } from '../components/ConfirmModal';
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
const WishNote = ({ wish, onFulfill }: { wish: Wish, onFulfill?: (id: string) => void }) => {
|
| 9 |
return (
|
| 10 |
<div className={`relative p-4 w-40 h-40 md:w-48 md:h-48 shadow-lg flex flex-col justify-between transition-transform hover:scale-105 hover:z-10 rotate-${Math.floor(Math.random()*6)-3} ${wish.status === 'FULFILLED' ? 'bg-gray-100 grayscale opacity-70' : 'bg-yellow-100'}`} style={{fontFamily: '"Comic Sans MS", cursive, sans-serif'}}>
|
|
|
|
| 11 |
<div className="absolute -top-3 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-red-500 shadow-sm border-2 border-red-700 z-20"></div>
|
|
|
|
| 12 |
<div className="text-xs text-gray-500 font-bold mb-1">{new Date(wish.createTime).toLocaleDateString()}</div>
|
| 13 |
+
<div className="flex-1 overflow-hidden text-sm text-gray-800 leading-relaxed font-medium break-words">{wish.content}</div>
|
|
|
|
|
|
|
| 14 |
<div className="mt-2 pt-2 border-t border-black/10 flex justify-between items-end">
|
| 15 |
<div className="text-xs font-bold text-gray-600 truncate max-w-[80px]">{wish.studentName}</div>
|
| 16 |
{onFulfill && wish.status === 'PENDING' && (
|
| 17 |
+
<button onClick={() => onFulfill(wish._id!)} className="bg-green-500 text-white p-1 rounded-full hover:bg-green-600 shadow-sm" title="实现愿望"><Check size={14}/></button>
|
|
|
|
|
|
|
| 18 |
)}
|
| 19 |
{wish.status === 'FULFILLED' && <CheckCircle size={16} className="text-green-600"/>}
|
| 20 |
</div>
|
|
|
|
| 22 |
);
|
| 23 |
};
|
| 24 |
|
|
|
|
| 25 |
const FeedbackItem = ({ fb, onStatusChange }: { fb: Feedback, onStatusChange?: (id: string, status: string, reply?: string) => void }) => {
|
| 26 |
const [replyText, setReplyText] = useState(fb.reply || '');
|
| 27 |
const [isReplying, setIsReplying] = useState(false);
|
| 28 |
+
const handleAction = (status: string) => { if (onStatusChange) { onStatusChange(fb._id!, status, isReplying ? replyText : undefined); setIsReplying(false); } };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
return (
|
| 30 |
<div className="bg-white p-4 rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow">
|
| 31 |
<div className="flex justify-between items-start mb-2">
|
| 32 |
+
<div className="flex items-center gap-2"><div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-bold text-xs">{fb.creatorName[0]}</div><div><div className="font-bold text-sm text-gray-800">{fb.creatorName} <span className="text-xs font-normal text-gray-400">({fb.creatorRole === 'STUDENT' ? '学生' : '老师'})</span></div><div className="text-[10px] text-gray-400">{new Date(fb.createTime).toLocaleString()}</div></div></div>
|
| 33 |
+
<div className={`px-2 py-1 rounded text-xs font-bold ${fb.status === 'PENDING' ? 'bg-gray-100 text-gray-500' : fb.status === 'ACCEPTED' ? 'bg-blue-100 text-blue-600' : fb.status === 'PROCESSED' ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'}`}>{fb.status === 'PENDING' ? '待处理' : fb.status === 'ACCEPTED' ? '已接受' : fb.status === 'PROCESSED' ? '已处理' : '已忽略'}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
</div>
|
| 35 |
+
<div className="bg-gray-50 p-3 rounded-lg text-sm text-gray-700 mb-3 whitespace-pre-wrap border border-gray-100">{fb.content}</div>
|
| 36 |
+
{fb.reply && !isReplying && <div className="bg-blue-50 p-3 rounded-lg text-sm text-blue-800 mb-3 border border-blue-100"><span className="font-bold mr-1">回复:</span> {fb.reply}</div>}
|
| 37 |
+
{isReplying && <div className="mb-3"><textarea className="w-full border rounded p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none" placeholder="请输入回复内容..." value={replyText} onChange={e => setReplyText(e.target.value)}/></div>}
|
| 38 |
+
{onStatusChange && (<div className="flex gap-2 justify-end">{fb.status === 'PENDING' && (<><button onClick={() => handleAction('IGNORED')} className="text-xs text-gray-400 hover:text-red-500 px-2 py-1">忽略</button><button onClick={() => handleAction('ACCEPTED')} className="text-xs bg-blue-100 text-blue-600 px-3 py-1 rounded hover:bg-blue-200 font-bold">接受</button><button onClick={() => { setIsReplying(true); if(isReplying && replyText) handleAction('PROCESSED'); }} className="text-xs bg-green-100 text-green-600 px-3 py-1 rounded hover:bg-green-200 font-bold">{isReplying ? '提交回复并完成' : '回复并处理'}</button></>)}{fb.status === 'ACCEPTED' && (<button onClick={() => { setIsReplying(true); if(isReplying && replyText) handleAction('PROCESSED'); }} className="text-xs bg-green-100 text-green-600 px-3 py-1 rounded hover:bg-green-200 font-bold">{isReplying ? '提交回复并完成' : '处理'}</button>)}</div>)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</div>
|
| 40 |
);
|
| 41 |
};
|
|
|
|
| 45 |
const [loading, setLoading] = useState(true);
|
| 46 |
const [wishes, setWishes] = useState<Wish[]>([]);
|
| 47 |
const [feedbackList, setFeedbackList] = useState<Feedback[]>([]);
|
|
|
|
|
|
|
| 48 |
const [teachers, setTeachers] = useState<User[]>([]);
|
| 49 |
const [studentInfo, setStudentInfo] = useState<Student | null>(null);
|
| 50 |
const [myPendingWish, setMyPendingWish] = useState<Wish | null>(null);
|
| 51 |
+
const [filterStatus, setFilterStatus] = useState<string>('PENDING,ACCEPTED,PROCESSED');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
const [wishContent, setWishContent] = useState('');
|
| 53 |
const [feedbackContent, setFeedbackContent] = useState('');
|
| 54 |
const [selectedTeacherId, setSelectedTeacherId] = useState('');
|
| 55 |
+
const [confirmModal, setConfirmModal] = useState<{isOpen: boolean, title: string, message: string, onConfirm: () => void}>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
|
| 56 |
|
| 57 |
const currentUser = api.auth.getCurrentUser();
|
| 58 |
const isStudent = currentUser?.role === 'STUDENT';
|
| 59 |
const isTeacher = currentUser?.role === 'TEACHER';
|
|
|
|
| 60 |
const isAdmin = currentUser?.role === 'ADMIN';
|
| 61 |
|
| 62 |
+
useEffect(() => { loadData(); }, [activeTab, filterStatus]);
|
|
|
|
|
|
|
| 63 |
|
| 64 |
const loadData = async () => {
|
| 65 |
setLoading(true);
|
| 66 |
try {
|
|
|
|
| 67 |
if (teachers.length === 0 && !isAdmin) {
|
|
|
|
| 68 |
if (isStudent) {
|
| 69 |
const stus = await api.students.getAll();
|
| 70 |
const me = stus.find((s: Student) => s.name === (currentUser?.trueName || currentUser?.username));
|
| 71 |
+
if (me) { setStudentInfo(me); const tList = await api.users.getTeachersForClass(me.className); setTeachers(tList); if (tList.length > 0) setSelectedTeacherId(tList[0]._id!); }
|
| 72 |
+
} else { const allTeachers = await api.users.getAll({ role: 'TEACHER' }); setTeachers(allTeachers); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
|
|
|
| 74 |
if (activeTab === 'wishes') {
|
| 75 |
+
if (isStudent && studentInfo) { const myWishes = await api.wishes.getAll({ studentId: studentInfo._id || String(studentInfo.id) }); setWishes(myWishes); setMyPendingWish(myWishes.find((w: Wish) => w.status === 'PENDING') || null); } else if (isTeacher) { const myWishes = await api.wishes.getAll({ teacherId: currentUser?._id }); setWishes(myWishes); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
} else if (activeTab === 'feedback') {
|
| 77 |
+
if (isStudent && studentInfo) { const myFb = await api.feedback.getAll({ creatorId: studentInfo._id || String(studentInfo.id), type: 'ACADEMIC' }); setFeedbackList(myFb); } else if (isTeacher) { const fbForMe = await api.feedback.getAll({ targetId: currentUser?._id, type: 'ACADEMIC', status: filterStatus }); setFeedbackList(fbForMe); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
} else if (activeTab === 'system') {
|
| 79 |
+
if (isAdmin) { const sysFb = await api.feedback.getAll({ type: 'SYSTEM' }); setFeedbackList(sysFb); } else { const mySysFb = await api.feedback.getAll({ creatorId: currentUser?._id, type: 'SYSTEM' }); setFeedbackList(mySysFb); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
+
} catch (e) { console.error(e); } finally { setLoading(false); }
|
|
|
|
|
|
|
| 82 |
};
|
| 83 |
|
|
|
|
|
|
|
| 84 |
const submitWish = async () => {
|
| 85 |
if (!wishContent.trim()) return alert('请输入愿望内容');
|
| 86 |
if (!selectedTeacherId) return alert('请选择许愿对象');
|
|
|
|
| 87 |
try {
|
| 88 |
const targetTeacher = teachers.find(t => t._id === selectedTeacherId);
|
| 89 |
+
await api.wishes.create({ studentId: studentInfo?._id || String(studentInfo?.id), studentName: studentInfo?.name, className: studentInfo?.className, teacherId: selectedTeacherId, teacherName: targetTeacher?.trueName || targetTeacher?.username, content: wishContent, status: 'PENDING' });
|
| 90 |
+
alert('许愿成功!愿望已挂上许愿树。'); setWishContent(''); loadData();
|
| 91 |
+
} catch (e: any) { alert(e.message || '许愿失败'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
};
|
| 93 |
|
| 94 |
const fulfillWish = async (id: string) => {
|
| 95 |
+
setConfirmModal({
|
| 96 |
+
isOpen: true,
|
| 97 |
+
title: '实现愿望',
|
| 98 |
+
message: '确定要实现这个愿望吗?',
|
| 99 |
+
onConfirm: async () => {
|
| 100 |
+
await api.wishes.fulfill(id);
|
| 101 |
+
loadData();
|
| 102 |
+
}
|
| 103 |
+
});
|
| 104 |
};
|
| 105 |
|
| 106 |
const randomFulfill = async () => {
|
| 107 |
+
setConfirmModal({
|
| 108 |
+
isOpen: true,
|
| 109 |
+
title: '随机抽取',
|
| 110 |
+
message: '系统将随机选择一个待实现的愿望,确定吗?',
|
| 111 |
+
onConfirm: async () => {
|
| 112 |
+
try {
|
| 113 |
+
const res = await api.wishes.randomFulfill(currentUser?._id!);
|
| 114 |
+
alert(`🎉 命运之选:${res.wish.studentName} 的愿望 "${res.wish.content}" 已被选中实现!`);
|
| 115 |
+
loadData();
|
| 116 |
+
} catch (e: any) { alert(e.message); }
|
| 117 |
+
}
|
| 118 |
+
});
|
| 119 |
};
|
| 120 |
|
| 121 |
const submitFeedback = async (type: 'ACADEMIC' | 'SYSTEM') => {
|
| 122 |
+
const content = type === 'ACADEMIC' ? feedbackContent : wishContent;
|
| 123 |
if (!content.trim()) return alert('请输入内容');
|
|
|
|
| 124 |
try {
|
| 125 |
+
const payload: Partial<Feedback> = { creatorId: type === 'ACADEMIC' ? (studentInfo?._id || String(studentInfo?.id)) : currentUser?._id, creatorName: type === 'ACADEMIC' ? studentInfo?.name : (currentUser?.trueName || currentUser?.username), creatorRole: currentUser?.role, content: content, type: type, status: 'PENDING' };
|
| 126 |
+
if (type === 'ACADEMIC') { if (!selectedTeacherId) return alert('请选择反馈对象'); const targetTeacher = teachers.find(t => t._id === selectedTeacherId); payload.targetId = selectedTeacherId; payload.targetName = targetTeacher?.trueName || targetTeacher?.username; } else { payload.targetId = 'ADMIN'; payload.targetName = '系统管理员'; }
|
| 127 |
+
await api.feedback.create(payload); alert('提交成功'); if (type === 'ACADEMIC') setFeedbackContent(''); else setWishContent(''); loadData();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
} catch(e) { alert('提交失败'); }
|
| 129 |
};
|
| 130 |
|
| 131 |
+
const handleFeedbackStatus = async (id: string, status: string, reply?: string) => { await api.feedback.update(id, { status, reply }); loadData(); };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
const handleIgnoreAll = async () => {
|
| 133 |
+
setConfirmModal({
|
| 134 |
+
isOpen: true,
|
| 135 |
+
title: '全部忽略',
|
| 136 |
+
message: '确定要忽略所有待处理的反馈吗?',
|
| 137 |
+
onConfirm: async () => {
|
| 138 |
+
await api.feedback.ignoreAll(currentUser?._id!);
|
| 139 |
+
loadData();
|
| 140 |
+
}
|
| 141 |
+
});
|
| 142 |
};
|
| 143 |
|
|
|
|
|
|
|
| 144 |
return (
|
| 145 |
<div className="h-full flex flex-col bg-slate-50 overflow-hidden">
|
| 146 |
+
<ConfirmModal isOpen={confirmModal.isOpen} title={confirmModal.title} message={confirmModal.message} onClose={()=>setConfirmModal({...confirmModal, isOpen: false})} onConfirm={confirmModal.onConfirm}/>
|
| 147 |
<div className="bg-white border-b border-gray-200 px-6 pt-4 flex gap-6 shrink-0 shadow-sm z-10">
|
| 148 |
+
<button onClick={() => setActiveTab('wishes')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'wishes' ? 'border-pink-500 text-pink-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Heart size={18} className={activeTab === 'wishes' ? 'fill-pink-500' : ''}/> 许愿树</button>
|
| 149 |
+
<button onClick={() => setActiveTab('feedback')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'feedback' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><MessageSquare size={18} className={activeTab === 'feedback' ? 'fill-blue-500' : ''}/> {isStudent ? '我的意见箱' : '学生反馈'}</button>
|
| 150 |
+
{!isStudent && <button onClick={() => setActiveTab('system')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'system' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}><Mail size={18} className={activeTab === 'system' ? 'fill-purple-500' : ''}/> 系统建议</button>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
</div>
|
|
|
|
| 152 |
<div className="flex-1 overflow-hidden relative">
|
| 153 |
{loading && <div className="absolute inset-0 bg-white/50 z-50 flex items-center justify-center"><Loader2 className="animate-spin text-blue-600"/></div>}
|
|
|
|
|
|
|
| 154 |
{activeTab === 'wishes' && (
|
| 155 |
<div className="h-full flex flex-col">
|
|
|
|
| 156 |
<div className="flex-1 bg-green-50 overflow-y-auto p-6 relative custom-scrollbar">
|
| 157 |
+
<div className="absolute inset-0 opacity-10 pointer-events-none flex justify-center items-center"><svg viewBox="0 0 200 200" className="w-full h-full text-green-800" fill="currentColor"><path d="M100 20 L140 100 H120 L150 160 H50 L80 100 H60 Z" /><rect x="90" y="160" width="20" height="40" /></svg></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
<div className="relative z-10 flex flex-wrap gap-6 justify-center content-start min-h-[300px]">
|
| 159 |
+
{wishes.length === 0 ? <div className="text-gray-400 mt-20 font-bold bg-white/80 p-4 rounded-xl shadow-sm">🌲 许愿树上还空荡荡的,快来挂上第一个愿望吧!</div> : wishes.map(w => (<WishNote key={w._id} wish={w} onFulfill={isTeacher ? fulfillWish : undefined} />))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
</div>
|
| 161 |
</div>
|
|
|
|
|
|
|
| 162 |
<div className="bg-white border-t border-gray-200 p-4 shrink-0 shadow-[0_-4px_10px_-1px_rgba(0,0,0,0.05)]">
|
| 163 |
+
{isStudent ? (myPendingWish ? (<div className="text-center p-4 bg-yellow-50 border border-yellow-200 rounded-xl"><p className="text-yellow-800 font-bold mb-1">您许下的愿望正在等待实现中...</p><p className="text-sm text-yellow-600">"{myPendingWish.content}"</p><p className="text-xs text-gray-400 mt-2">当老师实现此愿望后,您才可以许下新的愿望。</p></div>) : (<div className="flex flex-col md:flex-row gap-4 items-end max-w-4xl mx-auto"><div className="flex-1 w-full"><label className="text-xs font-bold text-gray-500 mb-1 block uppercase">许愿内容</label><input className="w-full border border-gray-300 rounded-lg p-3 focus:ring-2 focus:ring-pink-500 outline-none" placeholder="我希望..." value={wishContent} onChange={e => setWishContent(e.target.value)}/></div><div className="w-full md:w-48"><label className="text-xs font-bold text-gray-500 mb-1 block uppercase">许愿对象</label><select className="w-full border border-gray-300 rounded-lg p-3 bg-white outline-none" value={selectedTeacherId} onChange={e => setSelectedTeacherId(e.target.value)}>{teachers.map(t => <option key={t._id} value={t._id}>{t.teachingSubject ? `${t.teachingSubject}-${t.trueName||t.username}` : t.trueName||t.username}</option>)}</select></div><button onClick={submitWish} className="w-full md:w-auto bg-pink-500 text-white px-6 py-3 rounded-lg font-bold hover:bg-pink-600 flex items-center justify-center gap-2 shadow-md"><Send size={18}/> 挂上愿望</button></div>)) : isTeacher ? (<div className="flex justify-between items-center max-w-4xl mx-auto"><div className="text-sm text-gray-500"><span className="font-bold text-gray-800">{wishes.filter(w=>w.status==='PENDING').length}</span> 个待实现愿望</div><button onClick={randomFulfill} className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-6 py-3 rounded-lg font-bold hover:shadow-lg transition-all flex items-center gap-2"><Shuffle size={18}/> 随机实现一个愿望</button></div>) : <div className="text-center text-gray-400">只读模式</div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
</div>
|
| 165 |
</div>
|
| 166 |
)}
|
|
|
|
|
|
|
| 167 |
{activeTab === 'feedback' && (
|
| 168 |
<div className="h-full flex flex-col md:flex-row">
|
|
|
|
| 169 |
<div className="flex-1 overflow-y-auto p-6 bg-gray-50">
|
| 170 |
+
{isTeacher && (<div className="mb-4 flex justify-between items-center bg-white p-3 rounded-xl border border-gray-100 shadow-sm"><div className="flex items-center gap-2 text-sm text-gray-600"><Filter size={16}/><label className="flex items-center gap-1 cursor-pointer"><input type="checkbox" checked={filterStatus.includes('IGNORED')} onChange={e => { if(e.target.checked) setFilterStatus('PENDING,ACCEPTED,PROCESSED,IGNORED'); else setFilterStatus('PENDING,ACCEPTED,PROCESSED'); }} /> 显示已忽略</label></div><button onClick={handleIgnoreAll} className="text-xs text-red-500 hover:text-red-600 hover:bg-red-50 px-3 py-1.5 rounded flex items-center transition-colors"><Trash2 size={14} className="mr-1"/> 一键忽略所有待处理</button></div>)}
|
| 171 |
+
<div className="space-y-4 max-w-3xl mx-auto">{feedbackList.length === 0 ? <div className="text-center text-gray-400 py-10">暂无反馈记录</div> : feedbackList.map(fb => (<FeedbackItem key={fb._id} fb={fb} onStatusChange={isTeacher ? handleFeedbackStatus : undefined} />))}</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
</div>
|
| 173 |
+
{isStudent && (<div className="w-full md:w-80 bg-white border-l border-gray-200 p-6 flex flex-col shadow-xl z-20"><h3 className="font-bold text-gray-800 mb-4 flex items-center"><MessageSquare size={18} className="mr-2 text-blue-500"/> 提交反馈</h3><div className="space-y-4 flex-1"><div><label className="text-xs font-bold text-gray-500 mb-1 block uppercase">反馈对象</label><select className="w-full border border-gray-300 rounded-lg p-2 text-sm bg-white" value={selectedTeacherId} onChange={e => setSelectedTeacherId(e.target.value)}>{teachers.map(t => <option key={t._id} value={t._id}>{t.teachingSubject ? `${t.teachingSubject}-${t.trueName||t.username}` : t.trueName||t.username}</option>)}</select></div><div className="flex-1 flex flex-col"><label className="text-xs font-bold text-gray-500 mb-1 block uppercase">内容</label><textarea className="w-full border border-gray-300 rounded-lg p-3 text-sm flex-1 resize-none focus:ring-2 focus:ring-blue-500 outline-none" placeholder="老师,我对课程有建议..." value={feedbackContent} onChange={e => setFeedbackContent(e.target.value)}/></div><button onClick={() => submitFeedback('ACADEMIC')} className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 shadow-md">提交反馈</button></div></div>)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
</div>
|
| 175 |
)}
|
|
|
|
|
|
|
| 176 |
{activeTab === 'system' && (
|
| 177 |
<div className="h-full flex flex-col md:flex-row">
|
| 178 |
+
<div className="flex-1 overflow-y-auto p-6 bg-gray-50"><div className="max-w-3xl mx-auto space-y-4">{feedbackList.length === 0 ? <div className="text-center text-gray-400 py-10">暂无系统反馈</div> : feedbackList.map(fb => (<FeedbackItem key={fb._id} fb={fb} onStatusChange={isAdmin ? handleFeedbackStatus : undefined} />))}</div></div>
|
| 179 |
+
{!isAdmin && (<div className="w-full md:w-80 bg-white border-l border-gray-200 p-6 flex flex-col shadow-xl z-20"><h3 className="font-bold text-gray-800 mb-4 flex items-center"><Mail size={18} className="mr-2 text-purple-500"/> 联系管理员</h3><p className="text-xs text-gray-500 mb-4">如果您在使用系统中遇到问题或有优化建议,请在此反馈。</p><textarea className="w-full border border-gray-300 rounded-lg p-3 text-sm h-40 resize-none focus:ring-2 focus:ring-purple-500 outline-none mb-4" placeholder="描述您的问题或建议..." value={wishContent} onChange={e => setWishContent(e.target.value)}/><button onClick={() => submitFeedback('SYSTEM')} className="w-full bg-purple-600 text-white py-3 rounded-lg font-bold hover:bg-purple-700 shadow-md">发送反馈</button></div>)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
</div>
|
| 181 |
)}
|
| 182 |
</div>
|
| 183 |
</div>
|
| 184 |
);
|
| 185 |
+
};
|
server.js
CHANGED
|
@@ -3,7 +3,7 @@ const {
|
|
| 3 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 4 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 5 |
AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
|
| 6 |
-
WishModel, FeedbackModel
|
| 7 |
} = require('./models');
|
| 8 |
|
| 9 |
// Import AI Routes
|
|
@@ -16,25 +16,22 @@ const bodyParser = require('body-parser');
|
|
| 16 |
const path = require('path');
|
| 17 |
const compression = require('compression');
|
| 18 |
|
| 19 |
-
// ... constants
|
| 20 |
const PORT = 7860;
|
| 21 |
const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
|
| 22 |
|
| 23 |
const app = express();
|
| 24 |
|
| 25 |
-
// FIX: Disable compression for AI Chat SSE endpoint to allow real-time streaming
|
| 26 |
-
// Using req.originalUrl to match the full path regardless of mounting point
|
| 27 |
app.use(compression({
|
| 28 |
filter: (req, res) => {
|
| 29 |
if (req.originalUrl && req.originalUrl.includes('/api/ai/chat')) {
|
| 30 |
-
return false;
|
| 31 |
}
|
| 32 |
return compression.filter(req, res);
|
| 33 |
}
|
| 34 |
}));
|
| 35 |
|
| 36 |
app.use(cors());
|
| 37 |
-
app.use(bodyParser.json({ limit: '50mb' }));
|
| 38 |
app.use(express.static(path.join(__dirname, 'dist'), {
|
| 39 |
setHeaders: (res, filePath) => {
|
| 40 |
if (filePath.endsWith('.html')) {
|
|
@@ -45,7 +42,6 @@ app.use(express.static(path.join(__dirname, 'dist'), {
|
|
| 45 |
}
|
| 46 |
}));
|
| 47 |
|
| 48 |
-
// ... (DB Connection and helpers remain the same) ...
|
| 49 |
const InMemoryDB = { schools: [], users: [], isFallback: false };
|
| 50 |
const connectDB = async () => {
|
| 51 |
try {
|
|
@@ -76,7 +72,6 @@ const getQueryFilter = (req) => {
|
|
| 76 |
};
|
| 77 |
const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
|
| 78 |
|
| 79 |
-
// ... (existing helper functions) ...
|
| 80 |
const getGameOwnerFilter = async (req) => {
|
| 81 |
const role = req.headers['x-user-role'];
|
| 82 |
const username = req.headers['x-user-username'];
|
|
@@ -110,8 +105,63 @@ const generateStudentNo = async () => {
|
|
| 110 |
app.use('/api/ai', aiRoutes);
|
| 111 |
|
| 112 |
// ... (Rest of Existing Routes) ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
app.get('/api/classes/:className/teachers', async (req, res) => {
|
| 114 |
-
// ... existing code ...
|
| 115 |
const { className } = req.params;
|
| 116 |
const schoolId = req.headers['x-school-id'];
|
| 117 |
const normalize = (s) => (s || '').replace(/\s+/g, '');
|
|
@@ -148,78 +198,44 @@ app.get('/api/classes/:className/teachers', async (req, res) => {
|
|
| 148 |
} catch (e) { res.json([]); }
|
| 149 |
});
|
| 150 |
|
| 151 |
-
// --- WISH TREE ENDPOINTS ---
|
| 152 |
app.get('/api/wishes', async (req, res) => {
|
| 153 |
const { teacherId, studentId, status } = req.query;
|
| 154 |
const filter = getQueryFilter(req);
|
| 155 |
if (teacherId) filter.teacherId = teacherId;
|
| 156 |
if (studentId) filter.studentId = studentId;
|
| 157 |
if (status) filter.status = status;
|
| 158 |
-
|
| 159 |
-
// Sort pending first, then by createTime
|
| 160 |
res.json(await WishModel.find(filter).sort({ status: -1, createTime: -1 }));
|
| 161 |
});
|
| 162 |
|
| 163 |
app.post('/api/wishes', async (req, res) => {
|
| 164 |
const data = injectSchoolId(req, req.body);
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
studentId: data.studentId,
|
| 168 |
-
status: 'PENDING'
|
| 169 |
-
});
|
| 170 |
-
|
| 171 |
-
if (existing) {
|
| 172 |
-
return res.status(400).json({ error: 'LIMIT_REACHED', message: '您还有一个未实现的愿望,请等待实现后再许愿!' });
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
await WishModel.create(data);
|
| 176 |
res.json({ success: true });
|
| 177 |
});
|
| 178 |
|
| 179 |
app.post('/api/wishes/:id/fulfill', async (req, res) => {
|
| 180 |
-
|
| 181 |
-
await WishModel.findByIdAndUpdate(id, {
|
| 182 |
-
status: 'FULFILLED',
|
| 183 |
-
fulfillTime: new Date()
|
| 184 |
-
});
|
| 185 |
res.json({ success: true });
|
| 186 |
});
|
| 187 |
|
| 188 |
app.post('/api/wishes/random-fulfill', async (req, res) => {
|
| 189 |
const { teacherId } = req.body;
|
| 190 |
const pendingWishes = await WishModel.find({ teacherId, status: 'PENDING' });
|
| 191 |
-
|
| 192 |
-
if (pendingWishes.length === 0) {
|
| 193 |
-
return res.status(404).json({ error: 'NO_WISHES', message: '暂无待实现的愿望' });
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
const randomWish = pendingWishes[Math.floor(Math.random() * pendingWishes.length)];
|
| 197 |
-
await WishModel.findByIdAndUpdate(randomWish._id, {
|
| 198 |
-
status: 'FULFILLED',
|
| 199 |
-
fulfillTime: new Date()
|
| 200 |
-
});
|
| 201 |
-
|
| 202 |
res.json({ success: true, wish: randomWish });
|
| 203 |
});
|
| 204 |
|
| 205 |
-
// --- FEEDBACK WALL ENDPOINTS ---
|
| 206 |
app.get('/api/feedback', async (req, res) => {
|
| 207 |
const { targetId, creatorId, type, status } = req.query;
|
| 208 |
const filter = getQueryFilter(req);
|
| 209 |
if (targetId) filter.targetId = targetId;
|
| 210 |
if (creatorId) filter.creatorId = creatorId;
|
| 211 |
if (type) filter.type = type;
|
| 212 |
-
|
| 213 |
-
// Filter logic for status (comma separated allowed)
|
| 214 |
-
if (status) {
|
| 215 |
-
const statuses = status.split(',');
|
| 216 |
-
if (statuses.length > 1) {
|
| 217 |
-
filter.status = { $in: statuses };
|
| 218 |
-
} else {
|
| 219 |
-
filter.status = status;
|
| 220 |
-
}
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
res.json(await FeedbackModel.find(filter).sort({ createTime: -1 }));
|
| 224 |
});
|
| 225 |
|
|
@@ -230,27 +246,20 @@ app.post('/api/feedback', async (req, res) => {
|
|
| 230 |
});
|
| 231 |
|
| 232 |
app.put('/api/feedback/:id', async (req, res) => {
|
| 233 |
-
const { id } = req.params;
|
| 234 |
-
const { status, reply } = req.body;
|
| 235 |
const updateData = { updateTime: new Date() };
|
| 236 |
if (status) updateData.status = status;
|
| 237 |
if (reply !== undefined) updateData.reply = reply;
|
| 238 |
-
|
| 239 |
await FeedbackModel.findByIdAndUpdate(id, updateData);
|
| 240 |
res.json({ success: true });
|
| 241 |
});
|
| 242 |
|
| 243 |
app.post('/api/feedback/ignore-all', async (req, res) => {
|
| 244 |
const { targetId } = req.body;
|
| 245 |
-
await FeedbackModel.updateMany(
|
| 246 |
-
{ targetId, status: 'PENDING' },
|
| 247 |
-
{ status: 'IGNORED', updateTime: new Date() }
|
| 248 |
-
);
|
| 249 |
res.json({ success: true });
|
| 250 |
});
|
| 251 |
|
| 252 |
-
// ... (Rest of existing routes from server.js) ...
|
| 253 |
-
// ...
|
| 254 |
app.get('/api/games/lucky-config', async (req, res) => {
|
| 255 |
const filter = getQueryFilter(req);
|
| 256 |
if (req.query.ownerId) { filter.ownerId = req.query.ownerId; } else { const ownerFilter = await getGameOwnerFilter(req); Object.assign(filter, ownerFilter); }
|
|
@@ -428,7 +437,7 @@ app.post('/api/auth/login', async (req, res) => { const { username, password } =
|
|
| 428 |
app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
|
| 429 |
app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
|
| 430 |
app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 431 |
-
app.delete('/api/schools/:id', async (req, res) => { const schoolId = req.params.id; try { await School.findByIdAndDelete(schoolId); await User.deleteMany({ schoolId }); await Student.deleteMany({ schoolId }); await ClassModel.deleteMany({ schoolId }); await SubjectModel.deleteMany({ schoolId }); await Course.deleteMany({ schoolId }); await Score.deleteMany({ schoolId }); await ExamModel.deleteMany({ schoolId }); await ScheduleModel.deleteMany({ schoolId }); await NotificationModel.deleteMany({ schoolId }); await AttendanceModel.deleteMany({ schoolId }); await LeaveRequestModel.deleteMany({ schoolId }); await GameSessionModel.deleteMany({ schoolId }); await StudentRewardModel.deleteMany({ schoolId }); await LuckyDrawConfigModel.deleteMany({ schoolId }); await GameMonsterConfigModel.deleteMany({ schoolId }); await GameZenConfigModel.deleteMany({ schoolId }); await AchievementConfigModel.deleteMany({ schoolId }); await StudentAchievementModel.deleteMany({ schoolId }); await SchoolCalendarModel.deleteMany({ schoolId }); await WishModel.deleteMany({ schoolId }); await FeedbackModel.deleteMany({ schoolId }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
|
| 432 |
app.delete('/api/users/:id', async (req, res) => { const requesterRole = req.headers['x-user-role']; if (requesterRole === 'PRINCIPAL') { const user = await User.findById(req.params.id); if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'}); if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'}); } await User.findByIdAndDelete(req.params.id); res.json({}); });
|
| 433 |
app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
|
| 434 |
app.post('/api/students', async (req, res) => { const data = injectSchoolId(req, req.body); if (data.studentNo === '') delete data.studentNo; try { const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className }); if (existing) { Object.assign(existing, data); if (!existing.studentNo) { existing.studentNo = await generateStudentNo(); } await existing.save(); } else { if (!data.studentNo) { data.studentNo = await generateStudentNo(); } await Student.create(data); } res.json({ success: true }); } catch (e) { if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' }); res.status(500).json({ error: e.message }); } });
|
|
@@ -448,9 +457,6 @@ app.put('/api/scores/:id', async (req, res) => { await Score.findByIdAndUpdate(r
|
|
| 448 |
app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); });
|
| 449 |
app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); });
|
| 450 |
app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
|
| 451 |
-
app.get('/api/schedules', async (req, res) => { const query = { ...getQueryFilter(req), ...req.query }; if (query.grade) { query.className = { $regex: '^' + query.grade }; delete query.grade; } res.json(await ScheduleModel.find(query)); });
|
| 452 |
-
app.post('/api/schedules', async (req, res) => { const filter = { className:req.body.className, dayOfWeek:req.body.dayOfWeek, period:req.body.period }; const sId = req.headers['x-school-id']; if(sId) filter.schoolId = sId; await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
|
| 453 |
-
app.delete('/api/schedules', async (req, res) => { await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query}); res.json({}); });
|
| 454 |
app.get('/api/stats', async (req, res) => { const filter = getQueryFilter(req); const studentCount = await Student.countDocuments(filter); const courseCount = await Course.countDocuments(filter); const scores = await Score.find({...filter, status: 'Normal'}); let avgScore = 0; let excellentRate = '0%'; if (scores.length > 0) { const total = scores.reduce((sum, s) => sum + s.score, 0); avgScore = parseFloat((total / scores.length).toFixed(1)); const excellent = scores.filter(s => s.score >= 90).length; excellentRate = Math.round((excellent / scores.length) * 100) + '%'; } res.json({ studentCount, courseCount, avgScore, excellentRate }); });
|
| 455 |
app.get('/api/config', async (req, res) => { const currentSem = getAutoSemester(); let config = await ConfigModel.findOne({key:'main'}); if (config) { let semesters = config.semesters || []; if (!semesters.includes(currentSem)) { semesters.unshift(currentSem); config.semesters = semesters; config.semester = currentSem; await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem }); } } else { config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] }; } res.json(config); });
|
| 456 |
app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
|
|
|
|
| 3 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 4 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 5 |
AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
|
| 6 |
+
WishModel, FeedbackModel, TodoModel
|
| 7 |
} = require('./models');
|
| 8 |
|
| 9 |
// Import AI Routes
|
|
|
|
| 16 |
const path = require('path');
|
| 17 |
const compression = require('compression');
|
| 18 |
|
|
|
|
| 19 |
const PORT = 7860;
|
| 20 |
const MONGO_URI = 'mongodb+srv://dv890a:db8822723@chatpro.gw3v0v7.mongodb.net/chatpro?retryWrites=true&w=majority&appName=chatpro&authSource=admin';
|
| 21 |
|
| 22 |
const app = express();
|
| 23 |
|
|
|
|
|
|
|
| 24 |
app.use(compression({
|
| 25 |
filter: (req, res) => {
|
| 26 |
if (req.originalUrl && req.originalUrl.includes('/api/ai/chat')) {
|
| 27 |
+
return false;
|
| 28 |
}
|
| 29 |
return compression.filter(req, res);
|
| 30 |
}
|
| 31 |
}));
|
| 32 |
|
| 33 |
app.use(cors());
|
| 34 |
+
app.use(bodyParser.json({ limit: '50mb' }));
|
| 35 |
app.use(express.static(path.join(__dirname, 'dist'), {
|
| 36 |
setHeaders: (res, filePath) => {
|
| 37 |
if (filePath.endsWith('.html')) {
|
|
|
|
| 42 |
}
|
| 43 |
}));
|
| 44 |
|
|
|
|
| 45 |
const InMemoryDB = { schools: [], users: [], isFallback: false };
|
| 46 |
const connectDB = async () => {
|
| 47 |
try {
|
|
|
|
| 72 |
};
|
| 73 |
const injectSchoolId = (req, b) => ({ ...b, schoolId: req.headers['x-school-id'] });
|
| 74 |
|
|
|
|
| 75 |
const getGameOwnerFilter = async (req) => {
|
| 76 |
const role = req.headers['x-user-role'];
|
| 77 |
const username = req.headers['x-user-username'];
|
|
|
|
| 105 |
app.use('/api/ai', aiRoutes);
|
| 106 |
|
| 107 |
// ... (Rest of Existing Routes) ...
|
| 108 |
+
|
| 109 |
+
// --- TODO LIST ENDPOINTS ---
|
| 110 |
+
app.get('/api/todos', async (req, res) => {
|
| 111 |
+
const username = req.headers['x-user-username'];
|
| 112 |
+
if (!username) return res.status(401).json({ error: 'Unauthorized' });
|
| 113 |
+
const user = await User.findOne({ username });
|
| 114 |
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
| 115 |
+
res.json(await TodoModel.find({ userId: user._id.toString() }).sort({ isCompleted: 1, createTime: -1 }));
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
app.post('/api/todos', async (req, res) => {
|
| 119 |
+
const username = req.headers['x-user-username'];
|
| 120 |
+
const user = await User.findOne({ username });
|
| 121 |
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
| 122 |
+
await TodoModel.create({ ...req.body, userId: user._id.toString() });
|
| 123 |
+
res.json({ success: true });
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
app.put('/api/todos/:id', async (req, res) => {
|
| 127 |
+
await TodoModel.findByIdAndUpdate(req.params.id, req.body);
|
| 128 |
+
res.json({ success: true });
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
app.delete('/api/todos/:id', async (req, res) => {
|
| 132 |
+
await TodoModel.findByIdAndDelete(req.params.id);
|
| 133 |
+
res.json({ success: true });
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
// --- UPDATED SCHEDULE ENDPOINTS ---
|
| 137 |
+
app.get('/api/schedules', async (req, res) => {
|
| 138 |
+
const query = { ...getQueryFilter(req), ...req.query };
|
| 139 |
+
if (query.grade) { query.className = { $regex: '^' + query.grade }; delete query.grade; }
|
| 140 |
+
res.json(await ScheduleModel.find(query));
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
app.post('/api/schedules', async (req, res) => {
|
| 144 |
+
const filter = { className:req.body.className, dayOfWeek:req.body.dayOfWeek, period:req.body.period, weekType: req.body.weekType || 'ALL' };
|
| 145 |
+
const sId = req.headers['x-school-id'];
|
| 146 |
+
if(sId) filter.schoolId = sId;
|
| 147 |
+
await ScheduleModel.findOneAndUpdate(filter, injectSchoolId(req, req.body), {upsert:true});
|
| 148 |
+
res.json({});
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
app.delete('/api/schedules', async (req, res) => {
|
| 152 |
+
await ScheduleModel.deleteOne({...getQueryFilter(req), ...req.query});
|
| 153 |
+
res.json({});
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
// --- USER MENU ORDER ---
|
| 157 |
+
app.put('/api/users/:id/menu-order', async (req, res) => {
|
| 158 |
+
const { menuOrder } = req.body;
|
| 159 |
+
await User.findByIdAndUpdate(req.params.id, { menuOrder });
|
| 160 |
+
res.json({ success: true });
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
// --- Existing Routes (Minimally Modified) ---
|
| 164 |
app.get('/api/classes/:className/teachers', async (req, res) => {
|
|
|
|
| 165 |
const { className } = req.params;
|
| 166 |
const schoolId = req.headers['x-school-id'];
|
| 167 |
const normalize = (s) => (s || '').replace(/\s+/g, '');
|
|
|
|
| 198 |
} catch (e) { res.json([]); }
|
| 199 |
});
|
| 200 |
|
|
|
|
| 201 |
app.get('/api/wishes', async (req, res) => {
|
| 202 |
const { teacherId, studentId, status } = req.query;
|
| 203 |
const filter = getQueryFilter(req);
|
| 204 |
if (teacherId) filter.teacherId = teacherId;
|
| 205 |
if (studentId) filter.studentId = studentId;
|
| 206 |
if (status) filter.status = status;
|
|
|
|
|
|
|
| 207 |
res.json(await WishModel.find(filter).sort({ status: -1, createTime: -1 }));
|
| 208 |
});
|
| 209 |
|
| 210 |
app.post('/api/wishes', async (req, res) => {
|
| 211 |
const data = injectSchoolId(req, req.body);
|
| 212 |
+
const existing = await WishModel.findOne({ studentId: data.studentId, status: 'PENDING' });
|
| 213 |
+
if (existing) { return res.status(400).json({ error: 'LIMIT_REACHED', message: '您还有一个未实现的愿望,请等待实现后再许愿!' }); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
await WishModel.create(data);
|
| 215 |
res.json({ success: true });
|
| 216 |
});
|
| 217 |
|
| 218 |
app.post('/api/wishes/:id/fulfill', async (req, res) => {
|
| 219 |
+
await WishModel.findByIdAndUpdate(req.params.id, { status: 'FULFILLED', fulfillTime: new Date() });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
res.json({ success: true });
|
| 221 |
});
|
| 222 |
|
| 223 |
app.post('/api/wishes/random-fulfill', async (req, res) => {
|
| 224 |
const { teacherId } = req.body;
|
| 225 |
const pendingWishes = await WishModel.find({ teacherId, status: 'PENDING' });
|
| 226 |
+
if (pendingWishes.length === 0) { return res.status(404).json({ error: 'NO_WISHES', message: '暂无待实现的愿望' }); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
const randomWish = pendingWishes[Math.floor(Math.random() * pendingWishes.length)];
|
| 228 |
+
await WishModel.findByIdAndUpdate(randomWish._id, { status: 'FULFILLED', fulfillTime: new Date() });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
res.json({ success: true, wish: randomWish });
|
| 230 |
});
|
| 231 |
|
|
|
|
| 232 |
app.get('/api/feedback', async (req, res) => {
|
| 233 |
const { targetId, creatorId, type, status } = req.query;
|
| 234 |
const filter = getQueryFilter(req);
|
| 235 |
if (targetId) filter.targetId = targetId;
|
| 236 |
if (creatorId) filter.creatorId = creatorId;
|
| 237 |
if (type) filter.type = type;
|
| 238 |
+
if (status) { const statuses = status.split(','); if (statuses.length > 1) { filter.status = { $in: statuses }; } else { filter.status = status; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
res.json(await FeedbackModel.find(filter).sort({ createTime: -1 }));
|
| 240 |
});
|
| 241 |
|
|
|
|
| 246 |
});
|
| 247 |
|
| 248 |
app.put('/api/feedback/:id', async (req, res) => {
|
| 249 |
+
const { id } = req.params; const { status, reply } = req.body;
|
|
|
|
| 250 |
const updateData = { updateTime: new Date() };
|
| 251 |
if (status) updateData.status = status;
|
| 252 |
if (reply !== undefined) updateData.reply = reply;
|
|
|
|
| 253 |
await FeedbackModel.findByIdAndUpdate(id, updateData);
|
| 254 |
res.json({ success: true });
|
| 255 |
});
|
| 256 |
|
| 257 |
app.post('/api/feedback/ignore-all', async (req, res) => {
|
| 258 |
const { targetId } = req.body;
|
| 259 |
+
await FeedbackModel.updateMany( { targetId, status: 'PENDING' }, { status: 'IGNORED', updateTime: new Date() } );
|
|
|
|
|
|
|
|
|
|
| 260 |
res.json({ success: true });
|
| 261 |
});
|
| 262 |
|
|
|
|
|
|
|
| 263 |
app.get('/api/games/lucky-config', async (req, res) => {
|
| 264 |
const filter = getQueryFilter(req);
|
| 265 |
if (req.query.ownerId) { filter.ownerId = req.query.ownerId; } else { const ownerFilter = await getGameOwnerFilter(req); Object.assign(filter, ownerFilter); }
|
|
|
|
| 437 |
app.get('/api/schools', async (req, res) => { res.json(await School.find()); });
|
| 438 |
app.post('/api/schools', async (req, res) => { res.json(await School.create(req.body)); });
|
| 439 |
app.put('/api/schools/:id', async (req, res) => { await School.findByIdAndUpdate(req.params.id, req.body); res.json({}); });
|
| 440 |
+
app.delete('/api/schools/:id', async (req, res) => { const schoolId = req.params.id; try { await School.findByIdAndDelete(schoolId); await User.deleteMany({ schoolId }); await Student.deleteMany({ schoolId }); await ClassModel.deleteMany({ schoolId }); await SubjectModel.deleteMany({ schoolId }); await Course.deleteMany({ schoolId }); await Score.deleteMany({ schoolId }); await ExamModel.deleteMany({ schoolId }); await ScheduleModel.deleteMany({ schoolId }); await NotificationModel.deleteMany({ schoolId }); await AttendanceModel.deleteMany({ schoolId }); await LeaveRequestModel.deleteMany({ schoolId }); await GameSessionModel.deleteMany({ schoolId }); await StudentRewardModel.deleteMany({ schoolId }); await LuckyDrawConfigModel.deleteMany({ schoolId }); await GameMonsterConfigModel.deleteMany({ schoolId }); await GameZenConfigModel.deleteMany({ schoolId }); await AchievementConfigModel.deleteMany({ schoolId }); await StudentAchievementModel.deleteMany({ schoolId }); await SchoolCalendarModel.deleteMany({ schoolId }); await WishModel.deleteMany({ schoolId }); await FeedbackModel.deleteMany({ schoolId }); await TodoModel.deleteMany({}); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } });
|
| 441 |
app.delete('/api/users/:id', async (req, res) => { const requesterRole = req.headers['x-user-role']; if (requesterRole === 'PRINCIPAL') { const user = await User.findById(req.params.id); if (!user || user.schoolId !== req.headers['x-school-id']) return res.status(403).json({error: 'Permission denied'}); if (user.role === 'ADMIN') return res.status(403).json({error: 'Cannot delete admin'}); } await User.findByIdAndDelete(req.params.id); res.json({}); });
|
| 442 |
app.get('/api/students', async (req, res) => { res.json(await Student.find(getQueryFilter(req))); });
|
| 443 |
app.post('/api/students', async (req, res) => { const data = injectSchoolId(req, req.body); if (data.studentNo === '') delete data.studentNo; try { const existing = await Student.findOne({ schoolId: data.schoolId, name: data.name, className: data.className }); if (existing) { Object.assign(existing, data); if (!existing.studentNo) { existing.studentNo = await generateStudentNo(); } await existing.save(); } else { if (!data.studentNo) { data.studentNo = await generateStudentNo(); } await Student.create(data); } res.json({ success: true }); } catch (e) { if (e.code === 11000) return res.status(409).json({ error: 'DUPLICATE', message: '保存失败:存在重复的系统ID' }); res.status(500).json({ error: e.message }); } });
|
|
|
|
| 457 |
app.delete('/api/scores/:id', async (req, res) => { await Score.findByIdAndDelete(req.params.id); res.json({}); });
|
| 458 |
app.get('/api/exams', async (req, res) => { res.json(await ExamModel.find(getQueryFilter(req))); });
|
| 459 |
app.post('/api/exams', async (req, res) => { await ExamModel.findOneAndUpdate({name:req.body.name}, injectSchoolId(req, req.body), {upsert:true}); res.json({}); });
|
|
|
|
|
|
|
|
|
|
| 460 |
app.get('/api/stats', async (req, res) => { const filter = getQueryFilter(req); const studentCount = await Student.countDocuments(filter); const courseCount = await Course.countDocuments(filter); const scores = await Score.find({...filter, status: 'Normal'}); let avgScore = 0; let excellentRate = '0%'; if (scores.length > 0) { const total = scores.reduce((sum, s) => sum + s.score, 0); avgScore = parseFloat((total / scores.length).toFixed(1)); const excellent = scores.filter(s => s.score >= 90).length; excellentRate = Math.round((excellent / scores.length) * 100) + '%'; } res.json({ studentCount, courseCount, avgScore, excellentRate }); });
|
| 461 |
app.get('/api/config', async (req, res) => { const currentSem = getAutoSemester(); let config = await ConfigModel.findOne({key:'main'}); if (config) { let semesters = config.semesters || []; if (!semesters.includes(currentSem)) { semesters.unshift(currentSem); config.semesters = semesters; config.semester = currentSem; await ConfigModel.updateOne({ key: 'main' }, { semesters, semester: currentSem }); } } else { config = { key: 'main', allowRegister: true, semester: currentSem, semesters: [currentSem] }; } res.json(config); });
|
| 462 |
app.post('/api/config', async (req, res) => { await ConfigModel.findOneAndUpdate({key:'main'}, req.body, {upsert:true}); res.json({}); });
|
services/api.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
|
|
| 1 |
// ... existing imports
|
| 2 |
-
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig, Wish, Feedback, AIChatMessage } from '../types';
|
| 3 |
|
|
|
|
| 4 |
const getBaseUrl = () => {
|
| 5 |
let isProd = false;
|
| 6 |
try {
|
|
@@ -31,7 +33,6 @@ async function request(endpoint: string, options: RequestInit = {}) {
|
|
| 31 |
headers['x-school-id'] = currentUser.schoolId;
|
| 32 |
}
|
| 33 |
|
| 34 |
-
// Inject User Role for backend logic (e.g., bypassing draw limits)
|
| 35 |
if (currentUser?.role) {
|
| 36 |
headers['x-user-role'] = currentUser.role;
|
| 37 |
headers['x-user-username'] = currentUser.username;
|
|
@@ -104,7 +105,7 @@ export const api = {
|
|
| 104 |
getAll: () => request('/schools'),
|
| 105 |
add: (data: School) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
|
| 106 |
update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 107 |
-
delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' })
|
| 108 |
},
|
| 109 |
|
| 110 |
users: {
|
|
@@ -119,6 +120,7 @@ export const api = {
|
|
| 119 |
applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
|
| 120 |
request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
|
| 121 |
getTeachersForClass: (className: string) => request(`/classes/${encodeURIComponent(className)}/teachers`),
|
|
|
|
| 122 |
},
|
| 123 |
|
| 124 |
students: {
|
|
@@ -126,7 +128,6 @@ export const api = {
|
|
| 126 |
add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
|
| 127 |
update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 128 |
delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' }),
|
| 129 |
-
// NEW: Promote and Transfer
|
| 130 |
promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
|
| 131 |
transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) })
|
| 132 |
},
|
|
@@ -225,7 +226,6 @@ export const api = {
|
|
| 225 |
getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
|
| 226 |
grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
|
| 227 |
exchange: (data: { studentId: string, ruleId: string, teacherId?: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
|
| 228 |
-
// Teacher Exchange Rules
|
| 229 |
getMyRules: () => request('/achievements/teacher-rules'),
|
| 230 |
saveMyRules: (data: TeacherExchangeConfig) => request('/achievements/teacher-rules', { method: 'POST', body: JSON.stringify(data) }),
|
| 231 |
getRulesByTeachers: (teacherIds: string[]) => request(`/achievements/teacher-rules?teacherIds=${teacherIds.join(',')}`),
|
|
@@ -248,7 +248,6 @@ export const api = {
|
|
| 248 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 249 |
},
|
| 250 |
|
| 251 |
-
// NEW: Wish and Feedback API
|
| 252 |
wishes: {
|
| 253 |
getAll: (params: { teacherId?: string, studentId?: string, status?: string }) => {
|
| 254 |
const qs = new URLSearchParams(params as any).toString();
|
|
@@ -269,9 +268,15 @@ export const api = {
|
|
| 269 |
ignoreAll: (targetId: string) => request('/feedback/ignore-all', { method: 'POST', body: JSON.stringify({ targetId }) }),
|
| 270 |
},
|
| 271 |
|
| 272 |
-
// NEW: AI Endpoints
|
| 273 |
ai: {
|
| 274 |
chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
|
| 275 |
evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
}
|
| 277 |
-
};
|
|
|
|
| 1 |
+
|
| 2 |
// ... existing imports
|
| 3 |
+
import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig, Wish, Feedback, AIChatMessage, Todo } from '../types';
|
| 4 |
|
| 5 |
+
// ... existing getBaseUrl ...
|
| 6 |
const getBaseUrl = () => {
|
| 7 |
let isProd = false;
|
| 8 |
try {
|
|
|
|
| 33 |
headers['x-school-id'] = currentUser.schoolId;
|
| 34 |
}
|
| 35 |
|
|
|
|
| 36 |
if (currentUser?.role) {
|
| 37 |
headers['x-user-role'] = currentUser.role;
|
| 38 |
headers['x-user-username'] = currentUser.username;
|
|
|
|
| 105 |
getAll: () => request('/schools'),
|
| 106 |
add: (data: School) => request('/schools', { method: 'POST', body: JSON.stringify(data) }),
|
| 107 |
update: (id: string, data: Partial<School>) => request(`/schools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 108 |
+
delete: (id: string) => request(`/schools/${id}`, { method: 'DELETE' })
|
| 109 |
},
|
| 110 |
|
| 111 |
users: {
|
|
|
|
| 120 |
applyClass: (data: { userId: string, type: 'CLAIM'|'RESIGN', targetClass?: string, action: 'APPLY'|'APPROVE'|'REJECT' }) =>
|
| 121 |
request('/users/class-application', { method: 'POST', body: JSON.stringify(data) }),
|
| 122 |
getTeachersForClass: (className: string) => request(`/classes/${encodeURIComponent(className)}/teachers`),
|
| 123 |
+
saveMenuOrder: (userId: string, order: string[]) => request(`/users/${userId}/menu-order`, { method: 'PUT', body: JSON.stringify({ menuOrder: order }) }), // NEW
|
| 124 |
},
|
| 125 |
|
| 126 |
students: {
|
|
|
|
| 128 |
add: (data: any) => request('/students', { method: 'POST', body: JSON.stringify(data) }),
|
| 129 |
update: (id: string, data: any) => request(`/students/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 130 |
delete: (id: string | number) => request(`/students/${id}`, { method: 'DELETE' }),
|
|
|
|
| 131 |
promote: (data: { teacherFollows: boolean }) => request('/students/promote', { method: 'POST', body: JSON.stringify(data) }),
|
| 132 |
transfer: (data: { studentId: string, targetClass: string }) => request('/students/transfer', { method: 'POST', body: JSON.stringify(data) })
|
| 133 |
},
|
|
|
|
| 226 |
getStudentAchievements: (studentId: string, semester?: string) => request(`/achievements/student?studentId=${studentId}${semester ? `&semester=${semester}` : ''}`),
|
| 227 |
grant: (data: { studentId: string, achievementId: string, semester: string }) => request('/achievements/grant', { method: 'POST', body: JSON.stringify(data) }),
|
| 228 |
exchange: (data: { studentId: string, ruleId: string, teacherId?: string }) => request('/achievements/exchange', { method: 'POST', body: JSON.stringify(data) }),
|
|
|
|
| 229 |
getMyRules: () => request('/achievements/teacher-rules'),
|
| 230 |
saveMyRules: (data: TeacherExchangeConfig) => request('/achievements/teacher-rules', { method: 'POST', body: JSON.stringify(data) }),
|
| 231 |
getRulesByTeachers: (teacherIds: string[]) => request(`/achievements/teacher-rules?teacherIds=${teacherIds.join(',')}`),
|
|
|
|
| 248 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 249 |
},
|
| 250 |
|
|
|
|
| 251 |
wishes: {
|
| 252 |
getAll: (params: { teacherId?: string, studentId?: string, status?: string }) => {
|
| 253 |
const qs = new URLSearchParams(params as any).toString();
|
|
|
|
| 268 |
ignoreAll: (targetId: string) => request('/feedback/ignore-all', { method: 'POST', body: JSON.stringify({ targetId }) }),
|
| 269 |
},
|
| 270 |
|
|
|
|
| 271 |
ai: {
|
| 272 |
chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
|
| 273 |
evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
|
| 274 |
+
},
|
| 275 |
+
|
| 276 |
+
todos: { // NEW
|
| 277 |
+
getAll: () => request('/todos'),
|
| 278 |
+
add: (content: string) => request('/todos', { method: 'POST', body: JSON.stringify({ content }) }),
|
| 279 |
+
update: (id: string, data: Partial<Todo>) => request(`/todos/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
| 280 |
+
delete: (id: string) => request(`/todos/${id}`, { method: 'DELETE' }),
|
| 281 |
}
|
| 282 |
+
};
|
types.ts
CHANGED
|
@@ -1,70 +1,7 @@
|
|
| 1 |
|
| 2 |
-
// ... existing imports
|
| 3 |
-
|
| 4 |
-
// ... existing types
|
| 5 |
-
|
| 6 |
-
// AI Assistant Types
|
| 7 |
-
|
| 8 |
-
export interface AIChatMessage {
|
| 9 |
-
id: string;
|
| 10 |
-
role: 'user' | 'model';
|
| 11 |
-
text?: string;
|
| 12 |
-
audio?: string; // Base64 encoded audio for playback
|
| 13 |
-
isAudioMessage?: boolean; // Was the input/output audio based?
|
| 14 |
-
timestamp: number;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
export interface OralAssessment {
|
| 18 |
-
id: string;
|
| 19 |
-
topic: string;
|
| 20 |
-
difficulty: string;
|
| 21 |
-
studentAudio?: string;
|
| 22 |
-
score?: number;
|
| 23 |
-
feedback?: string;
|
| 24 |
-
transcription?: string; // What the AI heard
|
| 25 |
-
status: 'IDLE' | 'RECORDING' | 'ANALYZING' | 'COMPLETED';
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
// ... rest of the file
|
| 29 |
-
export type WishStatus = 'PENDING' | 'FULFILLED';
|
| 30 |
-
|
| 31 |
-
export interface Wish {
|
| 32 |
-
_id?: string;
|
| 33 |
-
schoolId: string;
|
| 34 |
-
studentId: string;
|
| 35 |
-
studentName: string;
|
| 36 |
-
className: string;
|
| 37 |
-
teacherId: string; // Target Teacher
|
| 38 |
-
teacherName: string;
|
| 39 |
-
content: string;
|
| 40 |
-
status: WishStatus;
|
| 41 |
-
createTime: string;
|
| 42 |
-
fulfillTime?: string;
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
export type FeedbackStatus = 'PENDING' | 'ACCEPTED' | 'PROCESSED' | 'IGNORED';
|
| 46 |
-
export type FeedbackType = 'ACADEMIC' | 'SYSTEM'; // ACADEMIC: Student->Teacher, SYSTEM: Staff->Admin
|
| 47 |
-
|
| 48 |
-
export interface Feedback {
|
| 49 |
-
_id?: string;
|
| 50 |
-
schoolId: string;
|
| 51 |
-
creatorId: string;
|
| 52 |
-
creatorName: string;
|
| 53 |
-
creatorRole: string;
|
| 54 |
-
targetId: string; // Teacher ID or 'ADMIN'
|
| 55 |
-
targetName: string;
|
| 56 |
-
content: string;
|
| 57 |
-
type: FeedbackType;
|
| 58 |
-
status: FeedbackStatus;
|
| 59 |
-
reply?: string; // Teacher/Admin reply
|
| 60 |
-
createTime: string;
|
| 61 |
-
updateTime?: string;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
// ... rest of the file (UserRole, etc.)
|
| 65 |
export enum UserRole {
|
| 66 |
ADMIN = 'ADMIN',
|
| 67 |
-
PRINCIPAL = 'PRINCIPAL',
|
| 68 |
TEACHER = 'TEACHER',
|
| 69 |
STUDENT = 'STUDENT',
|
| 70 |
USER = 'USER'
|
|
@@ -76,119 +13,82 @@ export enum UserStatus {
|
|
| 76 |
BANNED = 'banned'
|
| 77 |
}
|
| 78 |
|
| 79 |
-
export interface School {
|
| 80 |
-
id?: number;
|
| 81 |
-
_id?: string;
|
| 82 |
-
name: string;
|
| 83 |
-
code: string;
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
export interface User {
|
| 87 |
id?: number;
|
| 88 |
_id?: string;
|
| 89 |
username: string;
|
|
|
|
| 90 |
trueName?: string;
|
| 91 |
phone?: string;
|
| 92 |
email?: string;
|
| 93 |
schoolId?: string;
|
| 94 |
-
role: UserRole;
|
| 95 |
-
status: UserStatus;
|
| 96 |
avatar?: string;
|
| 97 |
createTime?: string;
|
| 98 |
teachingSubject?: string;
|
| 99 |
homeroomClass?: string;
|
| 100 |
-
// NEW: Feature Flag
|
| 101 |
aiAccess?: boolean;
|
| 102 |
-
|
| 103 |
classApplication?: {
|
| 104 |
type: 'CLAIM' | 'RESIGN';
|
| 105 |
targetClass?: string;
|
| 106 |
status: 'PENDING' | 'REJECTED';
|
| 107 |
};
|
| 108 |
-
// Student Registration Temp Fields
|
| 109 |
studentNo?: string;
|
| 110 |
parentName?: string;
|
| 111 |
parentPhone?: string;
|
| 112 |
address?: string;
|
| 113 |
gender?: 'Male' | 'Female';
|
| 114 |
seatNo?: string;
|
| 115 |
-
idCard?: string;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
export interface ClassInfo {
|
| 119 |
-
// ... (rest remains same)
|
| 120 |
-
id?: number;
|
| 121 |
-
_id?: string;
|
| 122 |
-
schoolId?: string;
|
| 123 |
-
grade: string;
|
| 124 |
-
className: string;
|
| 125 |
-
teacherName?: string; // Display string (e.g., "张三, 李四")
|
| 126 |
-
homeroomTeacherIds?: string[]; // Actual IDs for logic
|
| 127 |
-
studentCount?: number;
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
export interface Subject {
|
| 131 |
-
id?: number;
|
| 132 |
-
_id?: string;
|
| 133 |
-
schoolId?: string;
|
| 134 |
-
name: string;
|
| 135 |
-
code: string;
|
| 136 |
-
color: string;
|
| 137 |
-
excellenceThreshold?: number;
|
| 138 |
-
thresholds?: Record<string, number>; // Grade specific overrides e.g. {'一年级': 95}
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
export interface SystemConfig {
|
| 142 |
-
systemName: string;
|
| 143 |
-
semester: string;
|
| 144 |
-
semesters?: string[];
|
| 145 |
-
allowRegister: boolean;
|
| 146 |
-
allowAdminRegister: boolean;
|
| 147 |
-
allowPrincipalRegister?: boolean; // 新增:是否允许校长注册
|
| 148 |
-
allowStudentRegister?: boolean;
|
| 149 |
-
maintenanceMode: boolean;
|
| 150 |
-
emailNotify: boolean;
|
| 151 |
-
// NEW AI CONFIG
|
| 152 |
-
enableAI?: boolean;
|
| 153 |
-
aiTotalCalls?: number;
|
| 154 |
}
|
| 155 |
|
| 156 |
export interface Student {
|
| 157 |
id?: number;
|
| 158 |
_id?: string;
|
| 159 |
schoolId?: string;
|
| 160 |
-
studentNo: string;
|
| 161 |
-
seatNo?: string;
|
| 162 |
name: string;
|
| 163 |
-
gender: 'Male' | 'Female'
|
| 164 |
-
birthday: string;
|
| 165 |
-
idCard: string;
|
| 166 |
-
phone: string;
|
| 167 |
className: string;
|
| 168 |
status: 'Enrolled' | 'Graduated' | 'Suspended';
|
| 169 |
parentName?: string;
|
| 170 |
parentPhone?: string;
|
| 171 |
address?: string;
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
| 179 |
|
| 180 |
export interface Course {
|
| 181 |
id?: number;
|
| 182 |
_id?: string;
|
| 183 |
schoolId?: string;
|
| 184 |
-
courseCode: string;
|
| 185 |
-
courseName: string;
|
| 186 |
-
className: string; // Target Class (e.g. "一年级(1)班")
|
| 187 |
teacherName: string;
|
| 188 |
-
teacherId?: string;
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
export type ExamStatus = 'Normal' | 'Absent' | 'Leave' | 'Cheat';
|
|
@@ -201,20 +101,30 @@ export interface Score {
|
|
| 201 |
studentNo: string;
|
| 202 |
courseName: string;
|
| 203 |
score: number;
|
| 204 |
-
semester: string;
|
| 205 |
-
type:
|
| 206 |
examName?: string;
|
| 207 |
status?: ExamStatus;
|
| 208 |
}
|
| 209 |
|
| 210 |
-
export interface
|
| 211 |
id?: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
_id?: string;
|
| 213 |
schoolId?: string;
|
| 214 |
name: string;
|
| 215 |
date: string;
|
|
|
|
| 216 |
semester?: string;
|
| 217 |
-
type?: 'Midterm' | 'Final' | 'Quiz' | string;
|
| 218 |
}
|
| 219 |
|
| 220 |
export interface Schedule {
|
|
@@ -226,75 +136,106 @@ export interface Schedule {
|
|
| 226 |
subject: string;
|
| 227 |
dayOfWeek: number;
|
| 228 |
period: number;
|
|
|
|
| 229 |
}
|
| 230 |
|
| 231 |
-
export interface
|
| 232 |
id?: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
_id?: string;
|
| 234 |
schoolId?: string;
|
| 235 |
-
targetRole?:
|
| 236 |
targetUserId?: string;
|
| 237 |
title: string;
|
| 238 |
content: string;
|
| 239 |
type: 'info' | 'success' | 'warning' | 'error';
|
| 240 |
-
isRead?: boolean;
|
| 241 |
createTime: string;
|
| 242 |
}
|
| 243 |
|
| 244 |
-
export interface
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
}
|
| 250 |
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
export interface GameTeam {
|
| 254 |
id: string;
|
| 255 |
name: string;
|
| 256 |
score: number;
|
| 257 |
-
avatar: string;
|
| 258 |
color: string;
|
| 259 |
-
members: string[];
|
| 260 |
}
|
| 261 |
|
| 262 |
export interface GameRewardConfig {
|
| 263 |
-
scoreThreshold: number;
|
| 264 |
-
rewardType: '
|
| 265 |
-
rewardName: string;
|
| 266 |
-
rewardValue: number;
|
| 267 |
-
achievementId?: string;
|
| 268 |
}
|
| 269 |
|
| 270 |
export interface GameSession {
|
| 271 |
_id?: string;
|
| 272 |
schoolId: string;
|
| 273 |
-
className: string;
|
| 274 |
-
subject: string;
|
| 275 |
isEnabled: boolean;
|
|
|
|
| 276 |
teams: GameTeam[];
|
| 277 |
rewardsConfig: GameRewardConfig[];
|
| 278 |
-
maxSteps: number;
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
export enum RewardType {
|
| 282 |
-
ITEM = 'ITEM',
|
| 283 |
-
DRAW_COUNT = 'DRAW_COUNT',
|
| 284 |
-
CONSOLATION = 'CONSOLATION',
|
| 285 |
-
ACHIEVEMENT = 'ACHIEVEMENT'
|
| 286 |
}
|
| 287 |
|
| 288 |
export interface StudentReward {
|
| 289 |
_id?: string;
|
| 290 |
-
schoolId
|
| 291 |
-
studentId: string;
|
| 292 |
studentName: string;
|
| 293 |
-
rewardType:
|
| 294 |
name: string;
|
| 295 |
-
count
|
| 296 |
status: 'PENDING' | 'REDEEMED';
|
| 297 |
-
source: string;
|
| 298 |
createTime: string;
|
| 299 |
ownerId?: string;
|
| 300 |
}
|
|
@@ -302,70 +243,49 @@ export interface StudentReward {
|
|
| 302 |
export interface LuckyPrize {
|
| 303 |
id: string;
|
| 304 |
name: string;
|
| 305 |
-
probability: number;
|
| 306 |
-
count: number;
|
| 307 |
icon?: string;
|
| 308 |
}
|
| 309 |
|
| 310 |
export interface LuckyDrawConfig {
|
| 311 |
-
_id?: string;
|
| 312 |
-
schoolId: string;
|
| 313 |
-
className?: string;
|
| 314 |
-
ownerId?: string; // Isolated by teacher
|
| 315 |
-
prizes: LuckyPrize[];
|
| 316 |
-
dailyLimit: number;
|
| 317 |
-
cardCount?: number;
|
| 318 |
-
defaultPrize: string; // "再接再厉"
|
| 319 |
-
consolationWeight?: number; // Weight for NOT winning a main prize
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
export interface GameMonsterConfig {
|
| 323 |
_id?: string;
|
| 324 |
schoolId: string;
|
| 325 |
className: string;
|
| 326 |
-
ownerId?: string;
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
enabled: boolean;
|
| 333 |
-
type: 'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT';
|
| 334 |
-
val: string;
|
| 335 |
-
count: number;
|
| 336 |
-
};
|
| 337 |
}
|
| 338 |
|
| 339 |
-
// --- Achievement System Types ---
|
| 340 |
export interface AchievementItem {
|
| 341 |
id: string;
|
| 342 |
name: string;
|
| 343 |
-
icon: string;
|
| 344 |
-
points: number;
|
| 345 |
description?: string;
|
| 346 |
-
addedBy?: string;
|
| 347 |
-
addedByName?: string;
|
| 348 |
}
|
| 349 |
|
| 350 |
export interface ExchangeRule {
|
| 351 |
id: string;
|
| 352 |
-
cost: number;
|
| 353 |
-
rewardType: '
|
| 354 |
rewardName: string;
|
| 355 |
-
rewardValue: number;
|
| 356 |
}
|
| 357 |
|
| 358 |
-
// Class-based Config (Shared Achievement Library)
|
| 359 |
export interface AchievementConfig {
|
| 360 |
_id?: string;
|
| 361 |
schoolId: string;
|
| 362 |
className: string;
|
| 363 |
achievements: AchievementItem[];
|
| 364 |
-
|
| 365 |
-
exchangeRules?: ExchangeRule[];
|
| 366 |
}
|
| 367 |
|
| 368 |
-
// New: Teacher-based Config (Global Rules for a teacher)
|
| 369 |
export interface TeacherExchangeConfig {
|
| 370 |
_id?: string;
|
| 371 |
schoolId: string;
|
|
@@ -379,24 +299,21 @@ export interface StudentAchievement {
|
|
| 379 |
schoolId: string;
|
| 380 |
studentId: string;
|
| 381 |
studentName: string;
|
| 382 |
-
achievementId: string;
|
| 383 |
-
achievementName: string;
|
| 384 |
achievementIcon: string;
|
| 385 |
semester: string;
|
| 386 |
createTime: string;
|
| 387 |
}
|
| 388 |
|
| 389 |
-
// --- Attendance Types ---
|
| 390 |
-
export type AttendanceStatus = 'Present' | 'Absent' | 'Leave';
|
| 391 |
-
|
| 392 |
export interface Attendance {
|
| 393 |
_id?: string;
|
| 394 |
schoolId: string;
|
| 395 |
studentId: string;
|
| 396 |
studentName: string;
|
| 397 |
className: string;
|
| 398 |
-
date: string;
|
| 399 |
-
status:
|
| 400 |
checkInTime?: string;
|
| 401 |
}
|
| 402 |
|
|
@@ -413,12 +330,41 @@ export interface LeaveRequest {
|
|
| 413 |
createTime: string;
|
| 414 |
}
|
| 415 |
|
| 416 |
-
export interface
|
| 417 |
_id?: string;
|
| 418 |
schoolId: string;
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
}
|
|
|
|
| 1 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
export enum UserRole {
|
| 3 |
ADMIN = 'ADMIN',
|
| 4 |
+
PRINCIPAL = 'PRINCIPAL',
|
| 5 |
TEACHER = 'TEACHER',
|
| 6 |
STUDENT = 'STUDENT',
|
| 7 |
USER = 'USER'
|
|
|
|
| 13 |
BANNED = 'banned'
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
export interface User {
|
| 17 |
id?: number;
|
| 18 |
_id?: string;
|
| 19 |
username: string;
|
| 20 |
+
password?: string;
|
| 21 |
trueName?: string;
|
| 22 |
phone?: string;
|
| 23 |
email?: string;
|
| 24 |
schoolId?: string;
|
| 25 |
+
role: UserRole | string;
|
| 26 |
+
status: UserStatus | string;
|
| 27 |
avatar?: string;
|
| 28 |
createTime?: string;
|
| 29 |
teachingSubject?: string;
|
| 30 |
homeroomClass?: string;
|
|
|
|
| 31 |
aiAccess?: boolean;
|
| 32 |
+
menuOrder?: string[];
|
| 33 |
classApplication?: {
|
| 34 |
type: 'CLAIM' | 'RESIGN';
|
| 35 |
targetClass?: string;
|
| 36 |
status: 'PENDING' | 'REJECTED';
|
| 37 |
};
|
|
|
|
| 38 |
studentNo?: string;
|
| 39 |
parentName?: string;
|
| 40 |
parentPhone?: string;
|
| 41 |
address?: string;
|
| 42 |
gender?: 'Male' | 'Female';
|
| 43 |
seatNo?: string;
|
| 44 |
+
idCard?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
export interface Student {
|
| 48 |
id?: number;
|
| 49 |
_id?: string;
|
| 50 |
schoolId?: string;
|
| 51 |
+
studentNo: string;
|
| 52 |
+
seatNo?: string;
|
| 53 |
name: string;
|
| 54 |
+
gender: 'Male' | 'Female';
|
| 55 |
+
birthday?: string;
|
| 56 |
+
idCard?: string;
|
| 57 |
+
phone?: string;
|
| 58 |
className: string;
|
| 59 |
status: 'Enrolled' | 'Graduated' | 'Suspended';
|
| 60 |
parentName?: string;
|
| 61 |
parentPhone?: string;
|
| 62 |
address?: string;
|
| 63 |
+
teamId?: string;
|
| 64 |
+
drawAttempts?: number;
|
| 65 |
+
dailyDrawLog?: { date: string, count: number };
|
| 66 |
+
flowerBalance?: number;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export interface ClassInfo {
|
| 70 |
+
id?: number;
|
| 71 |
+
_id?: string;
|
| 72 |
+
schoolId?: string;
|
| 73 |
+
grade: string;
|
| 74 |
+
className: string;
|
| 75 |
+
teacherName?: string;
|
| 76 |
+
homeroomTeacherIds?: string[];
|
| 77 |
+
studentCount?: number;
|
| 78 |
}
|
| 79 |
|
| 80 |
export interface Course {
|
| 81 |
id?: number;
|
| 82 |
_id?: string;
|
| 83 |
schoolId?: string;
|
| 84 |
+
courseCode?: string;
|
| 85 |
+
courseName: string;
|
|
|
|
| 86 |
teacherName: string;
|
| 87 |
+
teacherId?: string;
|
| 88 |
+
className: string;
|
| 89 |
+
credits?: number;
|
| 90 |
+
capacity?: number;
|
| 91 |
+
enrolled?: number;
|
| 92 |
}
|
| 93 |
|
| 94 |
export type ExamStatus = 'Normal' | 'Absent' | 'Leave' | 'Cheat';
|
|
|
|
| 101 |
studentNo: string;
|
| 102 |
courseName: string;
|
| 103 |
score: number;
|
| 104 |
+
semester?: string;
|
| 105 |
+
type: string;
|
| 106 |
examName?: string;
|
| 107 |
status?: ExamStatus;
|
| 108 |
}
|
| 109 |
|
| 110 |
+
export interface Subject {
|
| 111 |
id?: number;
|
| 112 |
+
_id?: string;
|
| 113 |
+
schoolId?: string;
|
| 114 |
+
name: string;
|
| 115 |
+
code?: string;
|
| 116 |
+
color?: string;
|
| 117 |
+
excellenceThreshold?: number;
|
| 118 |
+
thresholds?: Record<string, number>;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export interface Exam {
|
| 122 |
_id?: string;
|
| 123 |
schoolId?: string;
|
| 124 |
name: string;
|
| 125 |
date: string;
|
| 126 |
+
type: string; // 'Midterm', 'Final', 'Quiz'
|
| 127 |
semester?: string;
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
export interface Schedule {
|
|
|
|
| 136 |
subject: string;
|
| 137 |
dayOfWeek: number;
|
| 138 |
period: number;
|
| 139 |
+
weekType?: 'ALL' | 'ODD' | 'EVEN';
|
| 140 |
}
|
| 141 |
|
| 142 |
+
export interface School {
|
| 143 |
id?: number;
|
| 144 |
+
_id?: string;
|
| 145 |
+
name: string;
|
| 146 |
+
code: string;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export interface Notification {
|
| 150 |
_id?: string;
|
| 151 |
schoolId?: string;
|
| 152 |
+
targetRole?: string;
|
| 153 |
targetUserId?: string;
|
| 154 |
title: string;
|
| 155 |
content: string;
|
| 156 |
type: 'info' | 'success' | 'warning' | 'error';
|
|
|
|
| 157 |
createTime: string;
|
| 158 |
}
|
| 159 |
|
| 160 |
+
export interface PeriodConfig {
|
| 161 |
+
period: number;
|
| 162 |
+
name: string;
|
| 163 |
+
startTime?: string;
|
| 164 |
+
endTime?: string;
|
| 165 |
}
|
| 166 |
|
| 167 |
+
export interface SystemConfig {
|
| 168 |
+
systemName: string;
|
| 169 |
+
semester: string;
|
| 170 |
+
semesters?: string[];
|
| 171 |
+
allowRegister: boolean;
|
| 172 |
+
allowAdminRegister: boolean;
|
| 173 |
+
allowPrincipalRegister?: boolean;
|
| 174 |
+
allowStudentRegister?: boolean;
|
| 175 |
+
maintenanceMode: boolean;
|
| 176 |
+
emailNotify: boolean;
|
| 177 |
+
enableAI?: boolean;
|
| 178 |
+
aiTotalCalls?: number;
|
| 179 |
+
periodConfig?: PeriodConfig[];
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
export interface SchoolCalendarEntry {
|
| 183 |
+
_id?: string;
|
| 184 |
+
schoolId: string;
|
| 185 |
+
className?: string;
|
| 186 |
+
type: 'HOLIDAY' | 'BREAK' | 'OFF' | 'WORKDAY';
|
| 187 |
+
startDate: string;
|
| 188 |
+
endDate: string;
|
| 189 |
+
name: string;
|
| 190 |
+
}
|
| 191 |
|
| 192 |
+
export interface Todo {
|
| 193 |
+
_id?: string;
|
| 194 |
+
userId: string;
|
| 195 |
+
content: string;
|
| 196 |
+
isCompleted: boolean;
|
| 197 |
+
createTime: string;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// Game & Rewards
|
| 201 |
export interface GameTeam {
|
| 202 |
id: string;
|
| 203 |
name: string;
|
| 204 |
score: number;
|
| 205 |
+
avatar: string;
|
| 206 |
color: string;
|
| 207 |
+
members: string[];
|
| 208 |
}
|
| 209 |
|
| 210 |
export interface GameRewardConfig {
|
| 211 |
+
scoreThreshold: number;
|
| 212 |
+
rewardType: 'DRAW_COUNT' | 'ITEM' | 'ACHIEVEMENT';
|
| 213 |
+
rewardName: string;
|
| 214 |
+
rewardValue: number;
|
| 215 |
+
achievementId?: string;
|
| 216 |
}
|
| 217 |
|
| 218 |
export interface GameSession {
|
| 219 |
_id?: string;
|
| 220 |
schoolId: string;
|
| 221 |
+
className: string;
|
| 222 |
+
subject: string;
|
| 223 |
isEnabled: boolean;
|
| 224 |
+
maxSteps: number;
|
| 225 |
teams: GameTeam[];
|
| 226 |
rewardsConfig: GameRewardConfig[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
}
|
| 228 |
|
| 229 |
export interface StudentReward {
|
| 230 |
_id?: string;
|
| 231 |
+
schoolId: string;
|
| 232 |
+
studentId: string;
|
| 233 |
studentName: string;
|
| 234 |
+
rewardType: 'DRAW_COUNT' | 'ITEM' | 'CONSOLATION' | 'ACHIEVEMENT';
|
| 235 |
name: string;
|
| 236 |
+
count: number;
|
| 237 |
status: 'PENDING' | 'REDEEMED';
|
| 238 |
+
source: string;
|
| 239 |
createTime: string;
|
| 240 |
ownerId?: string;
|
| 241 |
}
|
|
|
|
| 243 |
export interface LuckyPrize {
|
| 244 |
id: string;
|
| 245 |
name: string;
|
| 246 |
+
probability: number;
|
| 247 |
+
count: number;
|
| 248 |
icon?: string;
|
| 249 |
}
|
| 250 |
|
| 251 |
export interface LuckyDrawConfig {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
_id?: string;
|
| 253 |
schoolId: string;
|
| 254 |
className: string;
|
| 255 |
+
ownerId?: string;
|
| 256 |
+
prizes: LuckyPrize[];
|
| 257 |
+
dailyLimit: number;
|
| 258 |
+
cardCount: number;
|
| 259 |
+
defaultPrize: string;
|
| 260 |
+
consolationWeight: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
}
|
| 262 |
|
|
|
|
| 263 |
export interface AchievementItem {
|
| 264 |
id: string;
|
| 265 |
name: string;
|
| 266 |
+
icon: string;
|
| 267 |
+
points: number;
|
| 268 |
description?: string;
|
| 269 |
+
addedBy?: string;
|
| 270 |
+
addedByName?: string;
|
| 271 |
}
|
| 272 |
|
| 273 |
export interface ExchangeRule {
|
| 274 |
id: string;
|
| 275 |
+
cost: number;
|
| 276 |
+
rewardType: 'DRAW_COUNT' | 'ITEM';
|
| 277 |
rewardName: string;
|
| 278 |
+
rewardValue: number;
|
| 279 |
}
|
| 280 |
|
|
|
|
| 281 |
export interface AchievementConfig {
|
| 282 |
_id?: string;
|
| 283 |
schoolId: string;
|
| 284 |
className: string;
|
| 285 |
achievements: AchievementItem[];
|
| 286 |
+
exchangeRules: ExchangeRule[];
|
|
|
|
| 287 |
}
|
| 288 |
|
|
|
|
| 289 |
export interface TeacherExchangeConfig {
|
| 290 |
_id?: string;
|
| 291 |
schoolId: string;
|
|
|
|
| 299 |
schoolId: string;
|
| 300 |
studentId: string;
|
| 301 |
studentName: string;
|
| 302 |
+
achievementId: string;
|
| 303 |
+
achievementName: string;
|
| 304 |
achievementIcon: string;
|
| 305 |
semester: string;
|
| 306 |
createTime: string;
|
| 307 |
}
|
| 308 |
|
|
|
|
|
|
|
|
|
|
| 309 |
export interface Attendance {
|
| 310 |
_id?: string;
|
| 311 |
schoolId: string;
|
| 312 |
studentId: string;
|
| 313 |
studentName: string;
|
| 314 |
className: string;
|
| 315 |
+
date: string;
|
| 316 |
+
status: 'Present' | 'Absent' | 'Leave';
|
| 317 |
checkInTime?: string;
|
| 318 |
}
|
| 319 |
|
|
|
|
| 330 |
createTime: string;
|
| 331 |
}
|
| 332 |
|
| 333 |
+
export interface Wish {
|
| 334 |
_id?: string;
|
| 335 |
schoolId: string;
|
| 336 |
+
studentId: string;
|
| 337 |
+
studentName: string;
|
| 338 |
+
className?: string;
|
| 339 |
+
teacherId: string;
|
| 340 |
+
teacherName: string;
|
| 341 |
+
content: string;
|
| 342 |
+
status: 'PENDING' | 'FULFILLED';
|
| 343 |
+
createTime: string;
|
| 344 |
+
fulfillTime?: string;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
export interface Feedback {
|
| 348 |
+
_id?: string;
|
| 349 |
+
schoolId: string;
|
| 350 |
+
creatorId: string;
|
| 351 |
+
creatorName: string;
|
| 352 |
+
creatorRole: string;
|
| 353 |
+
targetId?: string;
|
| 354 |
+
targetName?: string;
|
| 355 |
+
content: string;
|
| 356 |
+
type: 'ACADEMIC' | 'SYSTEM';
|
| 357 |
+
status: 'PENDING' | 'ACCEPTED' | 'PROCESSED' | 'IGNORED';
|
| 358 |
+
reply?: string;
|
| 359 |
+
createTime: string;
|
| 360 |
+
updateTime?: string;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
export interface AIChatMessage {
|
| 364 |
+
id: string;
|
| 365 |
+
role: 'user' | 'model';
|
| 366 |
+
text?: string;
|
| 367 |
+
audio?: string;
|
| 368 |
+
isAudioMessage?: boolean;
|
| 369 |
+
timestamp: number;
|
| 370 |
}
|