- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
147 lines
5.1 KiB
TypeScript
147 lines
5.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|