Files
jiang13-bbs/frontend/app/login/page.tsx
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

147 lines
5.1 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 { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { MessagesSquare } from "lucide-react";
import { apiLogin, apiMe } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
// 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向
function safeRedirect(to: string | null): string {
if (!to || !to.startsWith("/") || to.startsWith("//") || to.startsWith("/\\")) {
return "/";
}
return to;
}
function RegisterHint() {
return (
<p className="meta text-center mt-5">
还没有账号?
<Link href="/register" className="ml-1 font-semibold" style={{ color: "var(--accent)" }}>
立即注册
</Link>
</p>
);
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const allowComments = useAllowComments();
const allowMessages = useAllowMessages();
const redirect = safeRedirect(searchParams.get("redirect"));
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [retryAfter, setRetryAfter] = useState(0);
// 登录限流倒计时:窗口期内禁用提交,避免无意义请求与提示条消失重现的闪烁
useEffect(() => {
if (retryAfter <= 0) return;
const t = setTimeout(() => setRetryAfter((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [retryAfter]);
// 已登录用户访问登录页:直接回落到安全的跳转目标
useEffect(() => {
if (!hasAuthCookieHint()) return;
apiMe()
.then((res) => {
if (res.user) router.replace(redirect);
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 提交不清空 error:提示条消失又重现会造成表单跳动(闪烁),输入时再清除
setLoading(true);
try {
const res = await apiLogin(username, password);
// 登录成功后后端通过 Set-Cookie 写入 HttpOnly JWT,前端无需存储 token
if (res.user) {
// root layout 在客户端导航中持久化,push 后再 refresh 让其重新取登录态
router.push(redirect);
router.refresh();
} else {
setError(res.error || "登录失败");
if (typeof res.retry_after === "number" && res.retry_after > 0) {
setRetryAfter(res.retry_after);
}
}
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto pt-8 sm:pt-14">
<div className="mb-7 text-center">
<Link
href="/"
aria-label="返回首页"
className="inline-flex w-14 h-14 rounded-2xl items-center justify-center mb-4 transition-transform duration-300 hover:rotate-[-6deg]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<MessagesSquare size={26} />
</Link>
<h1 className="text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
欢迎回来
</h1>
<p className="text-sm mt-2" style={{ color: "var(--ink-2)" }}>
登录后发帖{allowComments ? "、评论" : ""}{allowMessages ? "、发消息" : ""}与点赞
</p>
</div>
<form onSubmit={handleSubmit} className="panel p-6 sm:p-7 space-y-4">
{error && <div className="alert-error" role="alert">{error}</div>}
<div>
<label className="field-label" htmlFor="login-username">用户名</label>
<input
id="login-username"
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
setError("");
}}
required
autoFocus
autoComplete="username"
className="field"
placeholder="你的用户名"
/>
</div>
<div>
<label className="field-label" htmlFor="login-password">密码</label>
<input
id="login-password"
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError("");
}}
required
autoComplete="current-password"
className="field"
placeholder="输入密码"
/>
</div>
<button type="submit" disabled={loading || retryAfter > 0} className="btn btn-primary w-full justify-center mt-2">
{loading ? "登录中..." : retryAfter > 0 ? `${retryAfter} 秒后可重试` : "登录"}
</button>
</form>
<p className="text-center mt-4"><Link href="/reset-password">找回密码</Link></p>
<RegisterHint />
</div>
);
}