mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-04-18 14:32:54 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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("登录成功");
|
||||
|
||||
@@ -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("注册成功,请登录");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
60
web/src/app/frontend-api/auth/login/route.ts
Normal file
60
web/src/app/frontend-api/auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
57
web/src/app/frontend-api/auth/me/route.ts
Normal file
57
web/src/app/frontend-api/auth/me/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
61
web/src/lib/api/response.ts
Normal file
61
web/src/lib/api/response.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user