refactor: remove legacy frontend code and implement new Next.js structure

- Deleted the old Register page and utility functions.
- Removed Tailwind CSS configuration and Vite configuration files.
- Added a new script for starting a single container with FastAPI and Next.js.
- Updated README to reflect the current status of the Next.js frontend.
- Implemented new login and registration API routes with improved error handling.
- Refactored frontend API calls to use the new proxy structure.
- Enhanced error handling in API response processing.
- Updated components to align with the new API endpoints and structure.
This commit is contained in:
2026-04-17 21:15:06 +08:00
parent cab8b3b483
commit 9a1a9d3247
60 changed files with 819 additions and 7988 deletions

View File

@@ -1,14 +1,13 @@
# QQuiz Web
This directory contains the new Next.js frontend scaffold for the QQuiz
refactor.
This directory contains the Next.js frontend for QQuiz.
## Status
- App Router skeleton: added
- Auth/session proxy routes: added
- Legacy Vite frontend replacement: in progress
- shadcn/ui component foundation: added
- App Router application: active
- Auth/session proxy routes: active
- Single-container deployment target: active
- Split-stack frontend: active
## Environment

View File

@@ -9,6 +9,11 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
getResponseErrorMessage,
isRecord,
readResponsePayload
} from "@/lib/api/response";
export default function LoginPage() {
const router = useRouter();
@@ -22,7 +27,7 @@ export default function LoginPage() {
setLoading(true);
try {
const response = await fetch("/api/auth/login", {
const response = await fetch("/frontend-api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -30,9 +35,13 @@ export default function LoginPage() {
body: JSON.stringify({ username, password })
});
const payload = await response.json();
const payload = await readResponsePayload(response);
if (!response.ok) {
throw new Error(payload?.detail || "登录失败");
throw new Error(getResponseErrorMessage(payload, "登录失败"));
}
if (!isRecord(payload)) {
throw new Error("登录接口返回了无效响应");
}
toast.success("登录成功");

View File

@@ -9,6 +9,11 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
getResponseErrorMessage,
isRecord,
readResponsePayload
} from "@/lib/api/response";
export default function RegisterPage() {
const router = useRouter();
@@ -21,7 +26,7 @@ export default function RegisterPage() {
setLoading(true);
try {
const response = await fetch("/api/proxy/auth/register", {
const response = await fetch("/frontend-api/proxy/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -29,9 +34,13 @@ export default function RegisterPage() {
body: JSON.stringify({ username, password })
});
const payload = await response.json();
const payload = await readResponsePayload(response);
if (!response.ok) {
throw new Error(payload?.detail || "注册失败");
throw new Error(getResponseErrorMessage(payload, "注册失败"));
}
if (!isRecord(payload)) {
throw new Error("注册接口返回了无效响应");
}
toast.success("注册成功,请登录");

View File

@@ -1,37 +0,0 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config";
export async function POST(request: NextRequest) {
const body = await request.json();
const forwardedProto = request.headers.get("x-forwarded-proto");
const isSecureRequest =
request.nextUrl.protocol === "https:" || forwardedProto === "https";
const response = await fetch(buildBackendUrl("/auth/login"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body),
cache: "no-store"
});
const payload = await response.json();
if (!response.ok) {
return NextResponse.json(payload, { status: response.status });
}
cookies().set({
name: SESSION_COOKIE_NAME,
value: payload.access_token,
httpOnly: true,
sameSite: "lax",
secure: isSecureRequest,
path: "/"
});
return NextResponse.json({ ok: true });
}

View File

@@ -1,30 +0,0 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
export async function GET() {
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
}
const response = await fetch(buildBackendUrl("/auth/me"), {
headers: {
Authorization: `Bearer ${token}`
},
cache: "no-store"
});
const payload = await response.json();
if (response.status === 401) {
cookies().delete(SESSION_COOKIE_NAME);
}
return NextResponse.json(payload, { status: response.status });
}

View File

@@ -0,0 +1,60 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, buildBackendUrl } from "@/lib/api/config";
import {
getResponseErrorMessage,
isRecord,
readResponsePayload
} from "@/lib/api/response";
export async function POST(request: NextRequest) {
const body = await request.json();
const forwardedProto = request.headers.get("x-forwarded-proto");
const isSecureRequest =
request.nextUrl.protocol === "https:" || forwardedProto === "https";
let response: Response;
try {
response = await fetch(buildBackendUrl("/auth/login"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body),
cache: "no-store"
});
} catch {
return NextResponse.json(
{ detail: "Backend API is unavailable." },
{ status: 502 }
);
}
const payload = await readResponsePayload(response);
if (!response.ok) {
return NextResponse.json(
{ detail: getResponseErrorMessage(payload, "登录失败") },
{ status: response.status }
);
}
if (!isRecord(payload) || typeof payload.access_token !== "string") {
return NextResponse.json(
{ detail: "Backend returned an invalid login response." },
{ status: 502 }
);
}
cookies().set({
name: SESSION_COOKIE_NAME,
value: payload.access_token,
httpOnly: true,
sameSite: "lax",
secure: isSecureRequest,
path: "/"
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,57 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
import {
getResponseErrorMessage,
isRecord,
readResponsePayload
} from "@/lib/api/response";
export async function GET() {
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
}
let response: Response;
try {
response = await fetch(buildBackendUrl("/auth/me"), {
headers: {
Authorization: `Bearer ${token}`
},
cache: "no-store"
});
} catch {
return NextResponse.json(
{ detail: "Backend API is unavailable." },
{ status: 502 }
);
}
const payload = await readResponsePayload(response);
if (response.status === 401) {
cookies().delete(SESSION_COOKIE_NAME);
}
if (!response.ok) {
return NextResponse.json(
{ detail: getResponseErrorMessage(payload, "获取当前用户失败") },
{ status: response.status }
);
}
if (!isRecord(payload)) {
return NextResponse.json(
{ detail: "Backend returned an invalid auth response." },
{ status: 502 }
);
}
return NextResponse.json(payload, { status: response.status });
}

View File

@@ -16,13 +16,23 @@ export async function GET(
}
const target = `${buildBackendUrl(`/exams/${params.examId}/progress`)}?token=${encodeURIComponent(token)}`;
const response = await fetch(target, {
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache"
},
cache: "no-store"
});
let response: Response;
try {
response = await fetch(target, {
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache"
},
cache: "no-store"
});
} catch {
return new NextResponse("Backend API is unavailable.", {
status: 502,
headers: {
"Content-Type": "text/plain; charset=utf-8"
}
});
}
if (!response.ok || !response.body) {
const payload = await response.text();

View File

@@ -34,7 +34,16 @@ async function proxyRequest(
init.body = await request.arrayBuffer();
}
const response = await fetch(target, init);
let response: Response;
try {
response = await fetch(target, init);
} catch {
return NextResponse.json(
{ detail: "Backend API is unavailable." },
{ status: 502 }
);
}
const responseHeaders = new Headers(response.headers);
responseHeaders.delete("content-encoding");
responseHeaders.delete("content-length");

View File

@@ -10,7 +10,7 @@ export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
const response = await fetch("/api/auth/logout", {
const response = await fetch("/frontend-api/auth/logout", {
method: "POST"
});

View File

@@ -37,7 +37,7 @@ export function ExamDetailClient({
return;
}
const source = new EventSource(`/api/exams/${exam.id}/progress`);
const source = new EventSource(`/frontend-api/exams/${exam.id}/progress`);
eventSourceRef.current = source;
source.onmessage = (event) => {

View File

@@ -31,8 +31,12 @@ export function MistakePracticeClient() {
async function loadMistakes() {
setLoading(true);
try {
const payload = await browserApi<MistakeListResponse>("/mistakes/?skip=0&limit=1000", {
method: "GET"
const payload = await browserApi<MistakeListResponse>("/mistakes", {
method: "GET",
query: {
skip: 0,
limit: 1000
}
});
let nextMistakes = payload.mistakes;

View File

@@ -47,8 +47,12 @@ export function QuizPlayerClient({
const [examPayload, questionPayload, mistakesPayload] = await Promise.all([
browserApi<ExamSummary>(`/exams/${examId}`, { method: "GET" }),
browserApi<QuestionDetail>(`/questions/exam/${examId}/current`, { method: "GET" }),
browserApi<{ mistakes: Array<{ question_id: number }> }>("/mistakes/?skip=0&limit=1000", {
method: "GET"
browserApi<{ mistakes: Array<{ question_id: number }> }>("/mistakes", {
method: "GET",
query: {
skip: 0,
limit: 1000
}
})
]);

View File

@@ -1,4 +1,10 @@
import { buildProxyUrl } from "@/lib/api/config";
import {
getResponseErrorMessage,
getUnexpectedJsonMessage,
isRecord,
readResponsePayload
} from "@/lib/api/response";
type BrowserApiOptions = Omit<RequestInit, "body"> & {
body?: BodyInit | null;
@@ -27,7 +33,8 @@ export async function browserApi<T>(
options: BrowserApiOptions = {}
): Promise<T> {
const { query, headers, ...init } = options;
const response = await fetch(buildProxyUrl(path, buildSearchParams(query)), {
const target = buildProxyUrl(path, buildSearchParams(query));
const response = await fetch(target, {
...init,
headers: {
...(headers || {})
@@ -38,20 +45,18 @@ export async function browserApi<T>(
if (!response.ok) {
const fallback = `Request failed with status ${response.status}`;
try {
const data = await response.json();
throw new Error(data?.detail || fallback);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(fallback);
}
const payload = await readResponsePayload(response);
throw new Error(getResponseErrorMessage(payload, fallback));
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
const payload = await readResponsePayload(response);
if (!isRecord(payload) && !Array.isArray(payload)) {
throw new Error(getUnexpectedJsonMessage(response));
}
return payload as T;
}

View File

@@ -18,6 +18,6 @@ export function buildProxyUrl(path: string, search?: URLSearchParams) {
const query = search?.toString();
return query
? `/api/proxy/${normalizedPath}?${query}`
: `/api/proxy/${normalizedPath}`;
? `/frontend-api/proxy/${normalizedPath}?${query}`
: `/frontend-api/proxy/${normalizedPath}`;
}

View File

@@ -0,0 +1,61 @@
function isJsonContentType(contentType: string | null) {
return Boolean(contentType && (contentType.includes("application/json") || contentType.includes("+json")));
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export async function readResponsePayload(response: Response): Promise<unknown> {
if (response.status === 204 || response.status === 205) {
return null;
}
const raw = await response.text();
if (!raw) {
return null;
}
if (!isJsonContentType(response.headers.get("content-type"))) {
return raw;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return raw;
}
}
export function getResponseErrorMessage(payload: unknown, fallback: string) {
if (isRecord(payload)) {
const detail = payload.detail;
if (typeof detail === "string" && detail.trim()) {
return detail;
}
const message = payload.message;
if (typeof message === "string" && message.trim()) {
return message;
}
}
if (typeof payload === "string" && payload.trim()) {
const trimmed = payload.trim();
if (trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html")) {
return `${fallback} 接口返回了 HTML 而不是 JSON请检查前端代理和后端服务。`;
}
return trimmed;
}
return fallback;
}
export function getUnexpectedJsonMessage(response: Response) {
const contentType = response.headers.get("content-type") || "unknown content type";
if (contentType.includes("text/html")) {
return "接口返回了 HTML 而不是 JSON请检查前端代理和后端服务。";
}
return `接口返回了非 JSON 响应:${contentType}`;
}

View File

@@ -5,6 +5,12 @@ import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
import {
getResponseErrorMessage,
getUnexpectedJsonMessage,
isRecord,
readResponsePayload
} from "@/lib/api/response";
type ServerApiOptions = RequestInit & {
next?: { revalidate?: number };
@@ -31,20 +37,18 @@ export async function serverApi<T>(
if (!response.ok) {
const fallback = `Request failed with status ${response.status}`;
try {
const data = await response.json();
throw new Error(data?.detail || fallback);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(fallback);
}
const payload = await readResponsePayload(response);
throw new Error(getResponseErrorMessage(payload, fallback));
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
const payload = await readResponsePayload(response);
if (!isRecord(payload) && !Array.isArray(payload)) {
throw new Error(getUnexpectedJsonMessage(response));
}
return payload as T;
}