Files
CollegeAIcenter/frontend/src/app/login/page.tsx
T

234 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
/**
* 登录 / 注册页。
*
* 支持两种模式切换:登录与注册。注册时可选择角色(学生/导师/管理员),便于演示三端。
* 成功后依据角色跳转到对应工作台。错误信息取自后端统一错误体。
*/
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { homeForRole } from '@/components/app-shell';
import { ErrorBanner } from '@/components/ui';
import { ApiError } from '@/lib/api';
import { useAuth } from '@/lib/auth-context';
import { ROLE_LABELS, Role } from '@/lib/types';
/** 内置测试账户(与后端 SEED_TEST_ACCOUNTS 一致),用于一键登录。 */
const TEST_ACCOUNTS: {
username: string;
password: string;
role: Role;
}[] = [
{ username: 'student', password: 'student123', role: Role.Student },
{ username: 'mentor', password: 'mentor123', role: Role.Mentor },
{ username: 'admin', password: 'admin123', role: Role.Administrator },
];
export default function LoginPage() {
const { user, loading, login, register } = useAuth();
const router = useRouter();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [role, setRole] = useState<Role>(Role.Student);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [quickRole, setQuickRole] = useState<Role | null>(null);
// 已登录则直接跳转。
useEffect(() => {
if (!loading && user) {
router.replace(homeForRole(user.role));
}
}, [loading, user, router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const u =
mode === 'login'
? await login(username, password)
: await register({ username, password, role, displayName });
router.replace(homeForRole(u.role));
} catch (err) {
if (err instanceof ApiError) {
setError(err.body.message ?? '操作失败');
} else {
setError('网络错误,请确认后端服务已启动');
}
} finally {
setSubmitting(false);
}
}
/** 一键登录某测试账户。 */
async function quickLogin(acct: (typeof TEST_ACCOUNTS)[number]) {
setError(null);
setQuickRole(acct.role);
try {
const u = await login(acct.username, acct.password);
router.replace(homeForRole(u.role));
} catch (err) {
if (err instanceof ApiError) {
setError(
`${ROLE_LABELS[acct.role]}测试账户登录失败:${err.body.message ?? ''}`,
);
} else {
setError('网络错误,请确认后端服务已启动');
}
setQuickRole(null);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-brand-50 to-slate-100 px-4">
<div className="w-full max-w-md">
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold text-brand-700">
AI
</h1>
<p className="mt-1 text-sm text-slate-500">
· · · ·
</p>
</div>
<div className="card">
<div className="mb-5 grid grid-cols-2 gap-2 rounded-lg bg-slate-100 p-1">
{(['login', 'register'] as const).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
setError(null);
}}
className={`rounded-md py-2 text-sm font-medium transition ${
mode === m
? 'bg-white text-brand-700 shadow-sm'
: 'text-slate-500'
}`}
>
{m === 'login' ? '登录' : '注册'}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label"></label>
<input
className="input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="至少 3 个字符"
autoComplete="username"
required
/>
</div>
<div>
<label className="label"></label>
<input
className="input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 8 个字符"
autoComplete={
mode === 'login' ? 'current-password' : 'new-password'
}
required
/>
</div>
{mode === 'register' && (
<>
<div>
<label className="label"></label>
<input
className="input"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="如:张三"
required
/>
</div>
<div>
<label className="label"></label>
<div className="grid grid-cols-3 gap-2">
{Object.values(Role).map((r) => (
<button
type="button"
key={r}
onClick={() => setRole(r)}
className={`rounded-lg border px-3 py-2 text-sm transition ${
role === r
? 'border-brand-500 bg-brand-50 text-brand-700'
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
}`}
>
{ROLE_LABELS[r]}
</button>
))}
</div>
</div>
</>
)}
{error && <ErrorBanner message={error} />}
<button
type="submit"
className="btn-primary w-full"
disabled={submitting}
>
{submitting
? '处理中…'
: mode === 'login'
? '登录'
: '注册并登录'}
</button>
</form>
<div className="mt-5 border-t border-slate-100 pt-4">
<p className="mb-2 text-center text-xs text-slate-400">
·
</p>
<div className="grid grid-cols-3 gap-2">
{TEST_ACCOUNTS.map((acct) => (
<button
key={acct.role}
type="button"
onClick={() => quickLogin(acct)}
disabled={quickRole !== null}
className="flex flex-col items-center rounded-lg border border-slate-200 px-2 py-2 text-center transition hover:border-brand-400 hover:bg-brand-50 disabled:opacity-60"
>
<span className="text-sm font-medium text-slate-700">
{ROLE_LABELS[acct.role]}
</span>
<span className="mt-0.5 text-[11px] text-slate-400">
{quickRole === acct.role ? '登录中…' : acct.username}
</span>
</button>
))}
</div>
<p className="mt-2 text-center text-[11px] text-slate-300">
student123 / mentor123 / admin123
</p>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400">
http://localhost:3000
</p>
</div>
</div>
);
}