feat: rebuild CSP practice workflow, UX and automation
这个提交包含在:
@@ -1,38 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { API_BASE, apiFetch } from "@/lib/api";
|
||||
import { saveToken } from "@/lib/auth";
|
||||
|
||||
type AuthOk = { ok: true; user_id: number; token: string; expires_at: number };
|
||||
type AuthErr = { ok: false; error: string };
|
||||
type AuthResp = AuthOk | AuthErr;
|
||||
|
||||
export default function AuthPage() {
|
||||
const apiBase = useMemo(
|
||||
() => process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080",
|
||||
[]
|
||||
);
|
||||
function passwordScore(password: string): { label: string; color: string } {
|
||||
if (password.length >= 12) return { label: "强", color: "text-emerald-600" };
|
||||
if (password.length >= 8) return { label: "中", color: "text-blue-600" };
|
||||
return { label: "弱", color: "text-orange-600" };
|
||||
}
|
||||
|
||||
const [mode, setMode] = useState<"register" | "login">("register");
|
||||
const [username, setUsername] = useState(
|
||||
process.env.NEXT_PUBLIC_TEST_USERNAME ?? ""
|
||||
);
|
||||
const [password, setPassword] = useState(
|
||||
process.env.NEXT_PUBLIC_TEST_PASSWORD ?? ""
|
||||
);
|
||||
export default function AuthPage() {
|
||||
const router = useRouter();
|
||||
const apiBase = useMemo(() => API_BASE, []);
|
||||
|
||||
const [mode, setMode] = useState<"register" | "login">("login");
|
||||
const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
|
||||
const [password, setPassword] = useState(process.env.NEXT_PUBLIC_TEST_PASSWORD ?? "");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resp, setResp] = useState<AuthResp | null>(null);
|
||||
|
||||
const usernameErr = username.trim().length < 3 ? "用户名至少 3 位" : "";
|
||||
const passwordErr = password.length < 6 ? "密码至少 6 位" : "";
|
||||
const confirmErr =
|
||||
mode === "register" && password !== confirmPassword ? "两次密码不一致" : "";
|
||||
|
||||
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
setResp(null);
|
||||
try {
|
||||
const r = await fetch(`${apiBase}/api/v1/auth/${mode}`, {
|
||||
const j = await apiFetch<AuthResp>(`/api/v1/auth/${mode}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ username: username.trim(), password }),
|
||||
});
|
||||
const j = (await r.json()) as AuthResp;
|
||||
setResp(j);
|
||||
if (j.ok) {
|
||||
saveToken(j.token);
|
||||
setTimeout(() => {
|
||||
router.push("/problems");
|
||||
}, 350);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setResp({ ok: false, error: String(e) });
|
||||
} finally {
|
||||
@@ -40,75 +59,140 @@ export default function AuthPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const strength = passwordScore(password);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<main className="mx-auto max-w-xl px-6 py-12">
|
||||
<h1 className="text-2xl font-semibold">{mode === "register" ? "注册" : "登录"}</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
API Base: <span className="font-mono">{apiBase}</span>
|
||||
</p>
|
||||
<main className="mx-auto max-w-4xl px-6 py-10">
|
||||
<div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
|
||||
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
|
||||
<h1 className="text-2xl font-semibold">欢迎回来,开始刷题</h1>
|
||||
<p className="mt-3 text-sm text-zinc-300">
|
||||
登录后可提交评测、保存草稿、查看错题本和个人进度。
|
||||
</p>
|
||||
<div className="mt-6 space-y-2 text-sm text-zinc-300">
|
||||
<p>• 题库按 CSP-J / CSP-S / NOIP 入门组织</p>
|
||||
<p>• 题目页支持本地草稿与试运行</p>
|
||||
<p>• 生成式题解会异步入库,支持多解法</p>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-zinc-400">
|
||||
API Base: <span className="font-mono">{apiBase}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<button
|
||||
className={`rounded-lg px-3 py-2 text-sm ${
|
||||
mode === "register" ? "bg-zinc-900 text-white" : "bg-white border"
|
||||
}`}
|
||||
onClick={() => setMode("register")}
|
||||
disabled={loading}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-lg px-3 py-2 text-sm ${
|
||||
mode === "login" ? "bg-zinc-900 text-white" : "bg-white border"
|
||||
}`}
|
||||
onClick={() => setMode("login")}
|
||||
disabled={loading}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
<section className="rounded-2xl border bg-white p-6">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-3 py-2 ${
|
||||
mode === "login" ? "bg-white shadow-sm" : "text-zinc-600"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setResp(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-3 py-2 ${
|
||||
mode === "register" ? "bg-white shadow-sm" : "text-zinc-600"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
setResp(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-xl border bg-white p-5">
|
||||
<label className="block text-sm font-medium">用户名</label>
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border px-3 py-2"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="alice"
|
||||
/>
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">用户名</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="例如:csp_student"
|
||||
/>
|
||||
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>}
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block text-sm font-medium">密码(至少6位)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="mt-2 w-full rounded-lg border px-3 py-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="password123"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">密码</label>
|
||||
<span className={`text-xs ${strength.color}`}>强度:{strength.label}</span>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
{passwordErr && <p className="mt-1 text-xs text-red-600">{passwordErr}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mt-5 w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
onClick={submit}
|
||||
disabled={loading || !username || !password}
|
||||
>
|
||||
{loading ? "提交中..." : mode === "register" ? "注册" : "登录"}
|
||||
</button>
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">确认密码</label>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="再输入一次密码"
|
||||
/>
|
||||
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 rounded-xl border bg-white p-5">
|
||||
<h2 className="text-sm font-medium">响应</h2>
|
||||
<pre className="mt-3 overflow-auto rounded-lg bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{JSON.stringify(resp, null, 2)}
|
||||
</pre>
|
||||
{resp && resp.ok && (
|
||||
<p className="mt-3 text-xs text-zinc-600">
|
||||
你可以把 token 用在后续需要登录的接口里:
|
||||
<span className="font-mono"> Authorization: Bearer {resp.token}</span>
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-xs text-zinc-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPassword}
|
||||
onChange={(e) => setShowPassword(e.target.checked)}
|
||||
/>
|
||||
显示密码
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
onClick={() => void submit()}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{loading ? "提交中..." : mode === "register" ? "注册并登录" : "登录"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{resp && (
|
||||
<div
|
||||
className={`mt-4 rounded-lg border px-3 py-2 text-sm ${
|
||||
resp.ok ? "border-emerald-300 bg-emerald-50 text-emerald-700" : "border-red-300 bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{resp.ok
|
||||
? "登录成功,正在跳转到题库..."
|
||||
: `操作失败:${resp.error}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-zinc-500">
|
||||
登录后 Token 自动保存在浏览器 localStorage,可直接前往
|
||||
<Link className="mx-1 underline" href="/problems">
|
||||
题库
|
||||
</Link>
|
||||
与
|
||||
<Link className="mx-1 underline" href="/me">
|
||||
我的
|
||||
</Link>
|
||||
页面。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户