Spaces:
Running
Running
Upload 41 files
Browse files- App.tsx +5 -2
- components/Sidebar.tsx +2 -1
- pages/Login.tsx +5 -2
- pages/Profile.tsx +236 -0
- pages/StudentReports.tsx +1 -0
- server.js +44 -0
- services/api.ts +5 -1
App.tsx
CHANGED
|
@@ -15,6 +15,7 @@ const UserList = React.lazy(() => import('./pages/UserList').then(module => ({ d
|
|
| 15 |
const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
|
| 16 |
const Games = React.lazy(() => import('./pages/Games').then(module => ({ default: module.Games })));
|
| 17 |
const AttendancePage = React.lazy(() => import('./pages/Attendance').then(module => ({ default: module.AttendancePage })));
|
|
|
|
| 18 |
|
| 19 |
import { Login } from './pages/Login';
|
| 20 |
import { User, UserRole } from './types';
|
|
@@ -121,6 +122,7 @@ const AppContent: React.FC = () => {
|
|
| 121 |
case 'schools': return <SchoolList />;
|
| 122 |
case 'games': return <Games />;
|
| 123 |
case 'attendance': return <AttendancePage />;
|
|
|
|
| 124 |
default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
|
| 125 |
}
|
| 126 |
};
|
|
@@ -137,7 +139,8 @@ const AppContent: React.FC = () => {
|
|
| 137 |
users: '用户权限管理',
|
| 138 |
schools: '学校维度管理',
|
| 139 |
games: '互动教学中心',
|
| 140 |
-
attendance: '考勤管理'
|
|
|
|
| 141 |
};
|
| 142 |
|
| 143 |
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
|
|
@@ -175,4 +178,4 @@ const App: React.FC = () => {
|
|
| 175 |
);
|
| 176 |
};
|
| 177 |
|
| 178 |
-
export default App;
|
|
|
|
| 15 |
const SchoolList = React.lazy(() => import('./pages/SchoolList').then(module => ({ default: module.SchoolList })));
|
| 16 |
const Games = React.lazy(() => import('./pages/Games').then(module => ({ default: module.Games })));
|
| 17 |
const AttendancePage = React.lazy(() => import('./pages/Attendance').then(module => ({ default: module.AttendancePage })));
|
| 18 |
+
const Profile = React.lazy(() => import('./pages/Profile').then(module => ({ default: module.Profile })));
|
| 19 |
|
| 20 |
import { Login } from './pages/Login';
|
| 21 |
import { User, UserRole } from './types';
|
|
|
|
| 122 |
case 'schools': return <SchoolList />;
|
| 123 |
case 'games': return <Games />;
|
| 124 |
case 'attendance': return <AttendancePage />;
|
| 125 |
+
case 'profile': return <Profile />;
|
| 126 |
default: return <Dashboard onNavigate={(view) => setCurrentView(view)} />;
|
| 127 |
}
|
| 128 |
};
|
|
|
|
| 139 |
users: '用户权限管理',
|
| 140 |
schools: '学校维度管理',
|
| 141 |
games: '互动教学中心',
|
| 142 |
+
attendance: '考勤管理',
|
| 143 |
+
profile: '个人中心'
|
| 144 |
};
|
| 145 |
|
| 146 |
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 text-blue-600">加载中...</div>;
|
|
|
|
| 178 |
);
|
| 179 |
};
|
| 180 |
|
| 181 |
+
export default App;
|
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 } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
| 5 |
|
| 6 |
interface SidebarProps {
|
|
@@ -27,6 +27,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
|
|
| 27 |
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
| 28 |
{ id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 29 |
{ id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
|
|
|
| 30 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
|
| 31 |
];
|
| 32 |
|
|
|
|
| 1 |
|
| 2 |
import React from 'react';
|
| 3 |
+
import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle } from 'lucide-react';
|
| 4 |
import { UserRole } from '../types';
|
| 5 |
|
| 6 |
interface SidebarProps {
|
|
|
|
| 27 |
{ id: 'reports', label: '报表统计', icon: FileText, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
| 28 |
{ id: 'subjects', label: '学科设置', icon: Palette, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 29 |
{ id: 'users', label: '用户管理', icon: UserCog, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER] },
|
| 30 |
+
{ id: 'profile', label: '个人中心', icon: UserCircle, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
|
| 31 |
{ id: 'settings', label: '系统设置', icon: Settings, roles: [UserRole.ADMIN, UserRole.PRINCIPAL] },
|
| 32 |
];
|
| 33 |
|
pages/Login.tsx
CHANGED
|
@@ -10,7 +10,10 @@ interface LoginProps {
|
|
| 10 |
|
| 11 |
const AVATAR_SEEDS = [
|
| 12 |
'Felix', 'Aneka', 'Zoe', 'Jack', 'Precious', 'Bandit', 'Bubba', 'Cuddles',
|
| 13 |
-
'Miss
|
|
|
|
|
|
|
|
|
|
| 14 |
];
|
| 15 |
|
| 16 |
const gradeOrder: Record<string, number> = {
|
|
@@ -315,7 +318,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
|
| 315 |
const renderStep3 = () => (
|
| 316 |
<div className="space-y-4 animate-in fade-in">
|
| 317 |
<h3 className="text-center font-bold text-gray-700">第三步: 选择头像</h3>
|
| 318 |
-
<div className="grid grid-cols-4 gap-2 max-h-48 overflow-y-auto p-2 border rounded bg-gray-50">
|
| 319 |
{AVATAR_SEEDS.map(seed => {
|
| 320 |
const url = `https://api.dicebear.com/7.x/avataaars/svg?seed=${seed}`;
|
| 321 |
const isSelected = regForm.avatar === url;
|
|
|
|
| 10 |
|
| 11 |
const AVATAR_SEEDS = [
|
| 12 |
'Felix', 'Aneka', 'Zoe', 'Jack', 'Precious', 'Bandit', 'Bubba', 'Cuddles',
|
| 13 |
+
'Miss%20kitty', 'Loki', 'Sassy', 'Simba', 'Oliver', 'Princess', 'Garfield', 'Salem',
|
| 14 |
+
'Misty', 'Tigger', 'Bella', 'Pepper', 'Milo', 'Oscar', 'Lily', 'Cookie',
|
| 15 |
+
'Daisy', 'Molly', 'Jasper', 'George', 'Smokey', 'Pumpkin', 'Bear', 'Sam',
|
| 16 |
+
'Max', 'Buddy', 'Charlie', 'Rocky'
|
| 17 |
];
|
| 18 |
|
| 19 |
const gradeOrder: Record<string, number> = {
|
|
|
|
| 318 |
const renderStep3 = () => (
|
| 319 |
<div className="space-y-4 animate-in fade-in">
|
| 320 |
<h3 className="text-center font-bold text-gray-700">第三步: 选择头像</h3>
|
| 321 |
+
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto p-2 border rounded bg-gray-50 custom-scrollbar">
|
| 322 |
{AVATAR_SEEDS.map(seed => {
|
| 323 |
const url = `https://api.dicebear.com/7.x/avataaars/svg?seed=${seed}`;
|
| 324 |
const isSelected = regForm.avatar === url;
|
pages/Profile.tsx
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import { User } from '../types';
|
| 5 |
+
import { Save, Lock, User as UserIcon, Camera, Loader2, Link } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
const AVATAR_PRESETS = [
|
| 8 |
+
'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
| 9 |
+
'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka',
|
| 10 |
+
'https://api.dicebear.com/7.x/avataaars/svg?seed=Zoe',
|
| 11 |
+
'https://api.dicebear.com/7.x/avataaars/svg?seed=Jack',
|
| 12 |
+
'https://api.dicebear.com/7.x/avataaars/svg?seed=Precious',
|
| 13 |
+
'https://api.dicebear.com/7.x/avataaars/svg?seed=Bandit',
|
| 14 |
+
'https://api.dicebear.com/7.x/avataaars/svg?seed=Bubba',
|
| 15 |
+
'https://api.dicebear.com/7.x/avataaars/svg?seed=Cuddles',
|
| 16 |
+
'https://api.dicebear.com/7.x/bottts/svg?seed=Ginger',
|
| 17 |
+
'https://api.dicebear.com/7.x/bottts/svg?seed=Lucky',
|
| 18 |
+
'https://api.dicebear.com/7.x/bottts/svg?seed=Sassy',
|
| 19 |
+
'https://api.dicebear.com/7.x/bottts/svg?seed=Loki',
|
| 20 |
+
'https://api.dicebear.com/7.x/thumbs/svg?seed=Bandit',
|
| 21 |
+
'https://api.dicebear.com/7.x/thumbs/svg?seed=Miss%20kitty',
|
| 22 |
+
'https://api.dicebear.com/7.x/thumbs/svg?seed=Garfield',
|
| 23 |
+
'https://api.dicebear.com/7.x/fun-emoji/svg?seed=Simba',
|
| 24 |
+
'https://api.dicebear.com/7.x/fun-emoji/svg?seed=Misty',
|
| 25 |
+
'https://api.dicebear.com/7.x/fun-emoji/svg?seed=Tigger',
|
| 26 |
+
'https://api.dicebear.com/7.x/adventurer/svg?seed=Oliver',
|
| 27 |
+
'https://api.dicebear.com/7.x/adventurer/svg?seed=Bella',
|
| 28 |
+
'https://api.dicebear.com/7.x/adventurer/svg?seed=Pepper',
|
| 29 |
+
'https://api.dicebear.com/7.x/big-ears/svg?seed=Milo',
|
| 30 |
+
'https://api.dicebear.com/7.x/big-ears/svg?seed=Oscar',
|
| 31 |
+
'https://api.dicebear.com/7.x/big-ears/svg?seed=Lily',
|
| 32 |
+
'https://api.dicebear.com/7.x/micah/svg?seed=Bubba',
|
| 33 |
+
'https://api.dicebear.com/7.x/micah/svg?seed=Cookie',
|
| 34 |
+
'https://api.dicebear.com/7.x/micah/svg?seed=Daisy',
|
| 35 |
+
'https://api.dicebear.com/7.x/notionists/svg?seed=Molly',
|
| 36 |
+
'https://api.dicebear.com/7.x/notionists/svg?seed=Jasper',
|
| 37 |
+
'https://api.dicebear.com/7.x/notionists/svg?seed=George',
|
| 38 |
+
'https://api.dicebear.com/7.x/pixel-art/svg?seed=Smokey',
|
| 39 |
+
'https://api.dicebear.com/7.x/pixel-art/svg?seed=Pumpkin',
|
| 40 |
+
'https://api.dicebear.com/7.x/lorelei/svg?seed=Bear',
|
| 41 |
+
'https://api.dicebear.com/7.x/lorelei/svg?seed=Sam',
|
| 42 |
+
'https://api.dicebear.com/7.x/open-peeps/svg?seed=Max',
|
| 43 |
+
'https://api.dicebear.com/7.x/open-peeps/svg?seed=Buddy'
|
| 44 |
+
];
|
| 45 |
+
|
| 46 |
+
export const Profile: React.FC = () => {
|
| 47 |
+
const [user, setUser] = useState<User | null>(null);
|
| 48 |
+
const [loading, setLoading] = useState(true);
|
| 49 |
+
const [saving, setSaving] = useState(false);
|
| 50 |
+
|
| 51 |
+
// Forms
|
| 52 |
+
const [profileForm, setProfileForm] = useState({ trueName: '', phone: '', avatar: '' });
|
| 53 |
+
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
| 54 |
+
|
| 55 |
+
// UI State
|
| 56 |
+
const [customAvatar, setCustomAvatar] = useState('');
|
| 57 |
+
const [activeTab, setActiveTab] = useState<'info' | 'password'>('info');
|
| 58 |
+
|
| 59 |
+
useEffect(() => {
|
| 60 |
+
loadUser();
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
const loadUser = async () => {
|
| 64 |
+
setLoading(true);
|
| 65 |
+
try {
|
| 66 |
+
const u = await api.auth.refreshSession();
|
| 67 |
+
if (u) {
|
| 68 |
+
setUser(u);
|
| 69 |
+
setProfileForm({
|
| 70 |
+
trueName: u.trueName || '',
|
| 71 |
+
phone: u.phone || '',
|
| 72 |
+
avatar: u.avatar || ''
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
} catch(e) { console.error(e); }
|
| 76 |
+
finally { setLoading(false); }
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
const handleProfileUpdate = async () => {
|
| 80 |
+
setSaving(true);
|
| 81 |
+
try {
|
| 82 |
+
await api.auth.updateProfile({
|
| 83 |
+
userId: user?._id || user?.id,
|
| 84 |
+
...profileForm
|
| 85 |
+
});
|
| 86 |
+
alert('个人资料更新成功!');
|
| 87 |
+
loadUser();
|
| 88 |
+
} catch(e: any) { alert('更新失败: ' + e.message); }
|
| 89 |
+
finally { setSaving(false); }
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const handlePasswordUpdate = async () => {
|
| 93 |
+
if (!passwordForm.currentPassword || !passwordForm.newPassword) return alert('请填写完整');
|
| 94 |
+
if (passwordForm.newPassword !== passwordForm.confirmPassword) return alert('两次输入的新密码不一致');
|
| 95 |
+
if (passwordForm.newPassword.length < 6) return alert('新密码长度不能少于6位');
|
| 96 |
+
|
| 97 |
+
setSaving(true);
|
| 98 |
+
try {
|
| 99 |
+
await api.auth.updateProfile({
|
| 100 |
+
userId: user?._id || user?.id,
|
| 101 |
+
currentPassword: passwordForm.currentPassword,
|
| 102 |
+
newPassword: passwordForm.newPassword
|
| 103 |
+
});
|
| 104 |
+
alert('密码修改成功!下次登录请使用新密码。');
|
| 105 |
+
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
| 106 |
+
} catch(e: any) {
|
| 107 |
+
if (e.message === 'INVALID_PASSWORD') alert('当前密码错误,请重试');
|
| 108 |
+
else alert('修改失败: ' + e.message);
|
| 109 |
+
} finally { setSaving(false); }
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
if (loading || !user) return <div className="flex justify-center p-10"><Loader2 className="animate-spin text-blue-600"/></div>;
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div className="max-w-4xl mx-auto space-y-6 animate-in fade-in">
|
| 116 |
+
{/* Header Card */}
|
| 117 |
+
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex flex-col md:flex-row items-center gap-6">
|
| 118 |
+
<div className="relative group">
|
| 119 |
+
<img src={profileForm.avatar || user.avatar} alt="Avatar" className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover"/>
|
| 120 |
+
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer text-white text-xs font-bold" onClick={()=>document.getElementById('avatar-section')?.scrollIntoView({behavior:'smooth'})}>
|
| 121 |
+
<Camera size={24}/>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
<div className="text-center md:text-left flex-1">
|
| 125 |
+
<h2 className="text-2xl font-bold text-gray-800">{user.trueName || user.username}</h2>
|
| 126 |
+
<p className="text-gray-500">@{user.username} | {user.role === 'ADMIN' ? '管理员' : user.role === 'PRINCIPAL' ? '校长' : user.role === 'TEACHER' ? '教师' : '学生'}</p>
|
| 127 |
+
{user.schoolId && <p className="text-xs text-blue-600 bg-blue-50 inline-block px-2 py-1 rounded mt-2">ID: {user.schoolId}</p>}
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 132 |
+
{/* Sidebar Tabs */}
|
| 133 |
+
<div className="md:col-span-1 space-y-2">
|
| 134 |
+
<button onClick={()=>setActiveTab('info')} className={`w-full text-left px-4 py-3 rounded-lg flex items-center gap-3 transition-colors ${activeTab==='info' ? 'bg-blue-600 text-white shadow-md' : 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-100'}`}>
|
| 135 |
+
<UserIcon size={18}/> 基本资料 & 头像
|
| 136 |
+
</button>
|
| 137 |
+
<button onClick={()=>setActiveTab('password')} className={`w-full text-left px-4 py-3 rounded-lg flex items-center gap-3 transition-colors ${activeTab==='password' ? 'bg-blue-600 text-white shadow-md' : 'bg-white text-gray-600 hover:bg-gray-50 border border-gray-100'}`}>
|
| 138 |
+
<Lock size={18}/> 安全设置 (修改密码)
|
| 139 |
+
</button>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
{/* Content Area */}
|
| 143 |
+
<div className="md:col-span-2 bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
| 144 |
+
{activeTab === 'info' && (
|
| 145 |
+
<div className="space-y-6">
|
| 146 |
+
<h3 className="font-bold text-lg text-gray-800 border-b pb-2">基本资料</h3>
|
| 147 |
+
<div className="grid grid-cols-1 gap-4">
|
| 148 |
+
<div>
|
| 149 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">真实姓名</label>
|
| 150 |
+
<input className="w-full border rounded-lg p-2" value={profileForm.trueName} onChange={e=>setProfileForm({...profileForm, trueName:e.target.value})}/>
|
| 151 |
+
</div>
|
| 152 |
+
<div>
|
| 153 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">手机号码</label>
|
| 154 |
+
<input className="w-full border rounded-lg p-2" value={profileForm.phone} onChange={e=>setProfileForm({...profileForm, phone:e.target.value})}/>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div id="avatar-section" className="pt-4 border-t border-gray-100">
|
| 159 |
+
<h3 className="font-bold text-lg text-gray-800 mb-4">头像设置</h3>
|
| 160 |
+
|
| 161 |
+
{/* Custom URL Input */}
|
| 162 |
+
<div className="mb-4">
|
| 163 |
+
<label className="block text-xs font-bold text-gray-500 uppercase mb-1">使用网络图片链接</label>
|
| 164 |
+
<div className="flex gap-2">
|
| 165 |
+
<div className="relative flex-1">
|
| 166 |
+
<Link size={16} className="absolute left-3 top-3 text-gray-400"/>
|
| 167 |
+
<input
|
| 168 |
+
className="w-full border rounded-lg pl-9 pr-3 py-2 text-sm"
|
| 169 |
+
placeholder="https://example.com/avatar.png"
|
| 170 |
+
value={customAvatar}
|
| 171 |
+
onChange={e=>setCustomAvatar(e.target.value)}
|
| 172 |
+
/>
|
| 173 |
+
</div>
|
| 174 |
+
<button
|
| 175 |
+
onClick={() => { if(customAvatar) setProfileForm({...profileForm, avatar: customAvatar}); }}
|
| 176 |
+
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium"
|
| 177 |
+
>
|
| 178 |
+
应用
|
| 179 |
+
</button>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
{/* Preset Grid */}
|
| 184 |
+
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">或选择预设头像</label>
|
| 185 |
+
<div className="grid grid-cols-6 sm:grid-cols-8 gap-2 max-h-60 overflow-y-auto p-2 border rounded-lg bg-gray-50">
|
| 186 |
+
{AVATAR_PRESETS.map((url, idx) => (
|
| 187 |
+
<div
|
| 188 |
+
key={idx}
|
| 189 |
+
onClick={() => setProfileForm({...profileForm, avatar: url})}
|
| 190 |
+
className={`cursor-pointer rounded-full p-1 border-2 transition-all ${profileForm.avatar === url ? 'border-blue-500 scale-110 bg-blue-100 shadow-sm' : 'border-transparent hover:bg-white hover:shadow-sm'}`}
|
| 191 |
+
>
|
| 192 |
+
<img src={url} className="w-full h-auto rounded-full" loading="lazy"/>
|
| 193 |
+
</div>
|
| 194 |
+
))}
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div className="pt-4">
|
| 199 |
+
<button onClick={handleProfileUpdate} disabled={saving} className="bg-blue-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2">
|
| 200 |
+
{saving ? <Loader2 className="animate-spin" size={18}/> : <Save size={18}/>}
|
| 201 |
+
保存修改
|
| 202 |
+
</button>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
|
| 207 |
+
{activeTab === 'password' && (
|
| 208 |
+
<div className="space-y-6">
|
| 209 |
+
<h3 className="font-bold text-lg text-gray-800 border-b pb-2">修改密码</h3>
|
| 210 |
+
<div className="space-y-4 max-w-md">
|
| 211 |
+
<div>
|
| 212 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">当前密码</label>
|
| 213 |
+
<input type="password" className="w-full border rounded-lg p-2" value={passwordForm.currentPassword} onChange={e=>setPasswordForm({...passwordForm, currentPassword:e.target.value})}/>
|
| 214 |
+
</div>
|
| 215 |
+
<div>
|
| 216 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">新密码 (至少6位)</label>
|
| 217 |
+
<input type="password" className="w-full border rounded-lg p-2" value={passwordForm.newPassword} onChange={e=>setPasswordForm({...passwordForm, newPassword:e.target.value})}/>
|
| 218 |
+
</div>
|
| 219 |
+
<div>
|
| 220 |
+
<label className="block text-sm font-medium text-gray-700 mb-1">确认新密码</label>
|
| 221 |
+
<input type="password" className="w-full border rounded-lg p-2" value={passwordForm.confirmPassword} onChange={e=>setPasswordForm({...passwordForm, confirmPassword:e.target.value})}/>
|
| 222 |
+
</div>
|
| 223 |
+
<div className="pt-2">
|
| 224 |
+
<button onClick={handlePasswordUpdate} disabled={saving} className="bg-blue-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2">
|
| 225 |
+
{saving ? <Loader2 className="animate-spin" size={18}/> : <Save size={18}/>}
|
| 226 |
+
确认修改
|
| 227 |
+
</button>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
)}
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
);
|
| 236 |
+
};
|
pages/StudentReports.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { Score, Student, Subject, Exam } from '../types';
|
|
| 11 |
// Custom Tick for XAxis to highlight exams
|
| 12 |
const CustomizedAxisTick = (props: any) => {
|
| 13 |
const { x, y, payload, exams } = props;
|
|
|
|
| 14 |
const examInfo = exams.find((e: any) => (e.name || e.type) === payload.value);
|
| 15 |
const isMajor = examInfo?.type === 'Midterm' || examInfo?.type === 'Final' || payload.value.includes('期中') || payload.value.includes('期末');
|
| 16 |
|
|
|
|
| 11 |
// Custom Tick for XAxis to highlight exams
|
| 12 |
const CustomizedAxisTick = (props: any) => {
|
| 13 |
const { x, y, payload, exams } = props;
|
| 14 |
+
if (!payload || !payload.value) return null;
|
| 15 |
const examInfo = exams.find((e: any) => (e.name || e.type) === payload.value);
|
| 16 |
const isMajor = examInfo?.type === 'Midterm' || examInfo?.type === 'Final' || payload.value.includes('期中') || payload.value.includes('期末');
|
| 17 |
|
server.js
CHANGED
|
@@ -104,6 +104,50 @@ app.get('/api/auth/me', async (req, res) => {
|
|
| 104 |
res.json(user);
|
| 105 |
});
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
app.post('/api/auth/register', async (req, res) => {
|
| 108 |
const { role, username, password, schoolId, trueName, seatNo } = req.body;
|
| 109 |
const className = req.body.className || req.body.homeroomClass;
|
|
|
|
| 104 |
res.json(user);
|
| 105 |
});
|
| 106 |
|
| 107 |
+
app.post('/api/auth/update-profile', async (req, res) => {
|
| 108 |
+
const { userId, trueName, phone, avatar, currentPassword, newPassword } = req.body;
|
| 109 |
+
|
| 110 |
+
try {
|
| 111 |
+
const user = await User.findById(userId);
|
| 112 |
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
| 113 |
+
|
| 114 |
+
// If changing password, verify old one
|
| 115 |
+
if (newPassword) {
|
| 116 |
+
if (user.password !== currentPassword) {
|
| 117 |
+
return res.status(401).json({ error: 'INVALID_PASSWORD', message: '旧密码错误' });
|
| 118 |
+
}
|
| 119 |
+
user.password = newPassword;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
if (trueName) user.trueName = trueName;
|
| 123 |
+
if (phone) user.phone = phone;
|
| 124 |
+
if (avatar) user.avatar = avatar;
|
| 125 |
+
|
| 126 |
+
await user.save();
|
| 127 |
+
|
| 128 |
+
// If user is a student, sync profile data to Student collection
|
| 129 |
+
if (user.role === 'STUDENT') {
|
| 130 |
+
await Student.findOneAndUpdate(
|
| 131 |
+
{ studentNo: user.studentNo },
|
| 132 |
+
{ name: user.trueName || user.username, phone: user.phone }
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
// If user is a teacher, sync name to Class collection
|
| 136 |
+
if (user.role === 'TEACHER' && user.homeroomClass) {
|
| 137 |
+
const cls = await ClassModel.findOne({ schoolId: user.schoolId, $expr: { $eq: [{ $concat: ["$grade", "$className"] }, user.homeroomClass] } });
|
| 138 |
+
if (cls) {
|
| 139 |
+
cls.teacherName = user.trueName || user.username;
|
| 140 |
+
await cls.save();
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
res.json({ success: true, user });
|
| 145 |
+
} catch (e) {
|
| 146 |
+
console.error(e);
|
| 147 |
+
res.status(500).json({ error: e.message });
|
| 148 |
+
}
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
app.post('/api/auth/register', async (req, res) => {
|
| 152 |
const { role, username, password, schoolId, trueName, seatNo } = req.body;
|
| 153 |
const className = req.body.className || req.body.homeroomClass;
|
services/api.ts
CHANGED
|
@@ -47,6 +47,7 @@ async function request(endpoint: string, options: RequestInit = {}) {
|
|
| 47 |
if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
|
| 48 |
if (errorData.error === 'BANNED') throw new Error('BANNED');
|
| 49 |
if (errorData.error === 'CONFLICT') throw new Error(errorData.message);
|
|
|
|
| 50 |
throw new Error(errorMessage);
|
| 51 |
}
|
| 52 |
return res.json();
|
|
@@ -76,6 +77,9 @@ export const api = {
|
|
| 76 |
register: async (data: any): Promise<User> => {
|
| 77 |
return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
|
| 78 |
},
|
|
|
|
|
|
|
|
|
|
| 79 |
logout: () => {
|
| 80 |
if (typeof window !== 'undefined') {
|
| 81 |
localStorage.removeItem('user');
|
|
@@ -228,4 +232,4 @@ export const api = {
|
|
| 228 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 229 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 230 |
}
|
| 231 |
-
};
|
|
|
|
| 47 |
if (errorData.error === 'PENDING_APPROVAL') throw new Error('PENDING_APPROVAL');
|
| 48 |
if (errorData.error === 'BANNED') throw new Error('BANNED');
|
| 49 |
if (errorData.error === 'CONFLICT') throw new Error(errorData.message);
|
| 50 |
+
if (errorData.error === 'INVALID_PASSWORD') throw new Error('INVALID_PASSWORD');
|
| 51 |
throw new Error(errorMessage);
|
| 52 |
}
|
| 53 |
return res.json();
|
|
|
|
| 77 |
register: async (data: any): Promise<User> => {
|
| 78 |
return await request('/auth/register', { method: 'POST', body: JSON.stringify(data) });
|
| 79 |
},
|
| 80 |
+
updateProfile: async (data: any): Promise<any> => {
|
| 81 |
+
return await request('/auth/update-profile', { method: 'POST', body: JSON.stringify(data) });
|
| 82 |
+
},
|
| 83 |
logout: () => {
|
| 84 |
if (typeof window !== 'undefined') {
|
| 85 |
localStorage.removeItem('user');
|
|
|
|
| 232 |
batchDelete: (type: 'student' | 'score' | 'user', ids: string[]) => {
|
| 233 |
return request('/batch-delete', { method: 'POST', body: JSON.stringify({ type, ids }) });
|
| 234 |
}
|
| 235 |
+
};
|