管理面板功能完善,注册需要手机号

This commit is contained in:
yoyuzh
2026-03-20 10:35:04 +08:00
parent ff8d47f44f
commit 944ab6dbf8
21 changed files with 213 additions and 15 deletions

View File

@@ -151,7 +151,7 @@ export function PortalAdminUsersList() {
return (
<List
actions={<UsersListActions />}
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名邮箱" />]}
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名邮箱或手机号" />]}
perPage={25}
resource="users"
title="用户管理"
@@ -161,6 +161,7 @@ export function PortalAdminUsersList() {
<TextField source="id" label="ID" />
<TextField source="username" label="用户名" />
<TextField source="email" label="邮箱" />
<TextField source="phoneNumber" label="手机号" emptyText="-" />
<FunctionField<AdminUser>
label="角色"
render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />}

View File

@@ -138,6 +138,7 @@ export function Layout() {
}, [user]);
const email = user?.email || '暂无邮箱';
const phoneNumber = user?.phoneNumber || '未设置手机号';
const roleLabel = getRoleLabel(user?.role);
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
@@ -256,6 +257,7 @@ export function Layout() {
body: {
displayName: profileDraft.displayName.trim(),
email: profileDraft.email.trim(),
phoneNumber: profileDraft.phoneNumber.trim(),
bio: profileDraft.bio,
preferredLanguage: profileDraft.preferredLanguage,
},
@@ -497,11 +499,15 @@ export function Layout() {
</div>
<div>
<p className="text-sm font-medium text-white"></p>
<p className="text-xs text-slate-400 mt-0.5"></p>
<p className="text-xs text-slate-400 mt-0.5">{phoneNumber}</p>
</div>
</div>
<Button variant="outline" disabled className="border-white/10 text-slate-500">
<Button
variant="outline"
className="border-white/10 hover:bg-white/10 text-slate-300"
onClick={() => setActiveModal('settings')}
>
</Button>
</div>
@@ -586,6 +592,16 @@ export function Layout() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<Input
type="tel"
value={profileDraft.phoneNumber}
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300"></label>
<textarea

View File

@@ -20,6 +20,7 @@ test('buildAccountDraft prefers display name and fills fallback values', () => {
assert.deepEqual(buildAccountDraft(profile), {
displayName: 'Alice',
email: 'alice@example.com',
phoneNumber: '',
bio: '',
preferredLanguage: 'zh-CN',
});

View File

@@ -3,6 +3,7 @@ import type { AdminUserRole, UserProfile } from '@/src/lib/types';
export interface AccountDraft {
displayName: string;
email: string;
phoneNumber: string;
bio: string;
preferredLanguage: string;
}
@@ -11,6 +12,7 @@ export function buildAccountDraft(profile: UserProfile): AccountDraft {
return {
displayName: profile.displayName || profile.username,
email: profile.email,
phoneNumber: profile.phoneNumber || '',
bio: profile.bio || '',
preferredLanguage: profile.preferredLanguage || 'zh-CN',
};

View File

@@ -3,6 +3,7 @@ export interface UserProfile {
username: string;
displayName?: string | null;
email: string;
phoneNumber?: string | null;
bio?: string | null;
preferredLanguage?: string | null;
avatarUrl?: string | null;
@@ -22,6 +23,7 @@ export interface AdminUser {
id: number;
username: string;
email: string;
phoneNumber: string | null;
createdAt: string;
lastSchoolStudentId: string | null;
lastSchoolSemester: string | null;

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react';
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft } from 'lucide-react';
import { LogIn, User, Lock, UserPlus, Mail, ArrowLeft, Phone } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
@@ -22,6 +22,7 @@ export default function Login() {
const [password, setPassword] = useState('');
const [registerUsername, setRegisterUsername] = useState('');
const [registerEmail, setRegisterEmail] = useState('');
const [registerPhoneNumber, setRegisterPhoneNumber] = useState('');
const [registerPassword, setRegisterPassword] = useState('');
function switchMode(nextIsLogin: boolean) {
@@ -80,6 +81,7 @@ export default function Login() {
body: {
username: registerUsername.trim(),
email: registerEmail.trim(),
phoneNumber: registerPhoneNumber.trim(),
password: registerPassword,
},
});
@@ -284,6 +286,20 @@ export default function Login() {
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300 ml-1"></label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Input
type="tel"
placeholder="请输入11位手机号"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
value={registerPhoneNumber}
onChange={(event) => setRegisterPhoneNumber(event.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300 ml-1"></label>
<div className="relative">

View File

@@ -16,8 +16,9 @@ import {
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { apiRequest } from '@/src/lib/api';
import { apiDownload, apiRequest } from '@/src/lib/api';
import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { shouldLoadAvatarWithAuth } from '@/src/components/layout/account-utils';
import { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache';
import { cacheLatestSchoolData, fetchLatestSchoolData } from '@/src/lib/school';
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
@@ -74,6 +75,7 @@ export default function Overview() {
const [grades, setGrades] = useState<GradeResponse[]>(cachedOverview?.grades ?? cachedSchoolResults?.grades ?? []);
const [loadingError, setLoadingError] = useState('');
const [retryToken, setRetryToken] = useState(0);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const currentHour = new Date().getHours();
let greeting = '晚上好';
@@ -219,8 +221,53 @@ export default function Overview() {
};
}, [retryToken]);
useEffect(() => {
let active = true;
let objectUrl: string | null = null;
async function loadAvatar() {
if (!profile?.avatarUrl) {
if (active) {
setAvatarUrl(null);
}
return;
}
if (!shouldLoadAvatarWithAuth(profile.avatarUrl)) {
if (active) {
setAvatarUrl(profile.avatarUrl);
}
return;
}
try {
const response = await apiDownload(profile.avatarUrl);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
if (active) {
setAvatarUrl(objectUrl);
}
} catch {
if (active) {
setAvatarUrl(null);
}
}
}
void loadAvatar();
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [profile?.avatarUrl]);
const latestSemester = grades[0]?.semester ?? '--';
const previewCourses = schedule.slice(0, 3);
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
return (
<div className="space-y-6">
@@ -396,11 +443,15 @@ export default function Overview() {
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.02] border border-white/5">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl shadow-lg">
{(profile?.username?.[0] ?? 'T').toUpperCase()}
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl shadow-lg overflow-hidden">
{avatarUrl ? (
<img src={avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
profileAvatarFallback
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">{profile?.username ?? '未登录'}</p>
<p className="text-sm font-semibold text-white truncate">{profileDisplayName}</p>
<p className="text-xs text-slate-400 truncate mt-0.5">{profile?.email ?? '暂无邮箱'}</p>
</div>
</div>