feat(web): add simple auth UI + enable CORS for testing
这个提交包含在:
@@ -11,6 +11,28 @@ int main(int argc, char** argv) {
|
|||||||
|
|
||||||
csp::AppState::Instance().Init(db_path);
|
csp::AppState::Instance().Init(db_path);
|
||||||
|
|
||||||
|
// CORS (dev-friendly). In production, prefer reverse proxy same-origin.
|
||||||
|
drogon::app().registerPreRoutingAdvice([](const drogon::HttpRequestPtr& req,
|
||||||
|
drogon::AdviceCallback&& cb,
|
||||||
|
drogon::AdviceChainCallback&& chainCb) {
|
||||||
|
if (req->method() == drogon::Options) {
|
||||||
|
auto resp = drogon::HttpResponse::newHttpResponse();
|
||||||
|
resp->setStatusCode(drogon::k200OK);
|
||||||
|
resp->addHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
resp->addHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
|
||||||
|
resp->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
cb(resp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chainCb();
|
||||||
|
});
|
||||||
|
|
||||||
|
drogon::app().registerPostHandlingAdvice([](const drogon::HttpRequestPtr&,
|
||||||
|
const drogon::HttpResponsePtr& resp) {
|
||||||
|
resp->addHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
resp->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
});
|
||||||
|
|
||||||
drogon::app()
|
drogon::app()
|
||||||
.addListener("0.0.0.0", 8080)
|
.addListener("0.0.0.0", 8080)
|
||||||
.setThreadNum(4);
|
.setThreadNum(4);
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
environment:
|
environment:
|
||||||
# 前端调用后端API时使用;后续页面会读取该变量
|
# 浏览器侧请求后端的公共地址(本机测试用 localhost)
|
||||||
- NEXT_PUBLIC_API_BASE=http://localhost:8080
|
- NEXT_PUBLIC_API_BASE=http://localhost:8080
|
||||||
|
# Next.js 服务端反代用(可选),仅在你把 NEXT_PUBLIC_API_BASE 设为 /api 时需要
|
||||||
|
- BACKEND_INTERNAL_URL=http://backend:8080
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// For local dev convenience. In production you should configure reverse proxy
|
||||||
|
// and set NEXT_PUBLIC_API_BASE to your public API origin.
|
||||||
|
async rewrites() {
|
||||||
|
// If the user sets NEXT_PUBLIC_API_BASE to "/api", we can proxy to backend
|
||||||
|
// from the Next.js server (SSR). This does NOT affect browser fetch.
|
||||||
|
const backendInternal = process.env.BACKEND_INTERNAL_URL;
|
||||||
|
if (!backendInternal) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${backendInternal}/api/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
110
frontend/src/app/auth/page.tsx
普通文件
110
frontend/src/app/auth/page.tsx
普通文件
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
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",
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<"register" | "login">("register");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [resp, setResp] = useState<AuthResp | null>(null);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setLoading(true);
|
||||||
|
setResp(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${apiBase}/api/v1/auth/${mode}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const j = (await r.json()) as AuthResp;
|
||||||
|
setResp(j);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setResp({ ok: false, error: String(e) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,63 +1,46 @@
|
|||||||
import Image from "next/image";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
async function fetchHealth() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API_BASE}/api/health`, { cache: "no-store" });
|
||||||
|
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` } as const;
|
||||||
|
return (await r.json()) as { ok: boolean; version?: string };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { ok: false, error: String(e) } as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const health = await fetchHealth();
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||||
<Image
|
<h1 className="text-3xl font-semibold">CSP 在线练习平台(MVP)</h1>
|
||||||
className="dark:invert"
|
<p className="mt-2 text-sm text-zinc-600">
|
||||||
src="/next.svg"
|
API Base: <span className="font-mono">{API_BASE}</span>
|
||||||
alt="Next.js logo"
|
</p>
|
||||||
width={100}
|
|
||||||
height={20}
|
<div className="mt-6 rounded-xl border bg-white p-5">
|
||||||
priority
|
<h2 className="text-lg font-medium">后端状态</h2>
|
||||||
/>
|
<pre className="mt-3 overflow-auto rounded-lg bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
{JSON.stringify(health, null, 2)}
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
</pre>
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
<div className="mt-6 flex gap-3">
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
<Link
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
className="rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800"
|
||||||
target="_blank"
|
href="/auth"
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
<Image
|
注册 / 登录
|
||||||
className="dark:invert"
|
</Link>
|
||||||
src="/vercel.svg"
|
</div>
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
<div className="mt-10 text-sm text-zinc-500">
|
||||||
height={16}
|
说明:当前前端仅用于验证注册/登录与基础连通性;题库/提交/比赛/判题会在后续迭代补齐。
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户