完善文档与前端迁移,补充开源协议

This commit is contained in:
2026-04-17 19:48:13 +08:00
parent 466fa50aa8
commit 31916e68a6
94 changed files with 7019 additions and 480 deletions

5
web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
npm-debug.log*
.env
.env.*

1
web/.env.example Normal file
View File

@@ -0,0 +1 @@
API_BASE_URL=http://localhost:8000

33
web/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

15
web/README.md Normal file
View File

@@ -0,0 +1,15 @@
# QQuiz Web
This directory contains the new Next.js frontend scaffold for the QQuiz
refactor.
## Status
- App Router skeleton: added
- Auth/session proxy routes: added
- Legacy Vite frontend replacement: in progress
- shadcn/ui component foundation: added
## Environment
Copy `.env.example` and point `API_BASE_URL` at the FastAPI backend.

18
web/components.json Normal file
View File

@@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
}
}

5
web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

6
web/next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone"
};
export default nextConfig;

1817
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
web/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "qquiz-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-query": "^5.51.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.462.0",
"next": "^14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2"
}
}

6
web/postcss.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,44 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { UserManagementPanel } from "@/components/admin/user-management-panel";
import { requireAdminUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { UserListResponse } from "@/lib/types";
export default async function AdminPage({
searchParams
}: {
searchParams?: {
page?: string | string[];
search?: string | string[];
};
}) {
await requireAdminUser();
const page = parsePositiveInt(searchParams?.page);
const search = Array.isArray(searchParams?.search)
? searchParams?.search[0]
: searchParams?.search;
const query = new URLSearchParams({
skip: String(getOffset(page, DEFAULT_PAGE_SIZE)),
limit: String(DEFAULT_PAGE_SIZE)
});
if (search?.trim()) {
query.set("search", search.trim());
}
const data = await serverApi<UserListResponse>(`/admin/users?${query.toString()}`);
return (
<div className="space-y-8">
<PageHeader eyebrow="Admin" title="用户管理" />
<UserManagementPanel
initialPage={page}
initialSearch={search?.trim() || ""}
initialTotal={data.total}
initialUsers={data.users}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { SettingsPanel } from "@/components/admin/settings-panel";
import { PageHeader } from "@/components/app-shell/page-header";
import { requireAdminUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import { SystemConfigResponse } from "@/lib/types";
export default async function AdminSettingsPage() {
await requireAdminUser();
const config = await serverApi<SystemConfigResponse>("/admin/config");
return (
<div className="space-y-8">
<PageHeader eyebrow="Settings" title="系统设置" />
<SettingsPanel initialConfig={config} />
</div>
);
}

View File

@@ -0,0 +1,107 @@
import Link from "next/link";
import { BookOpen, FolderOpen, Shield, TriangleAlert } from "lucide-react";
import { PageHeader } from "@/components/app-shell/page-header";
import { StatCard } from "@/components/app-shell/stat-card";
import { StatusBadge } from "@/components/app-shell/status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatRelativeTime } from "@/lib/formatters";
import { requireCurrentUser } from "@/lib/auth/guards";
import { serverApi } from "@/lib/api/server";
import {
AdminStatisticsResponse,
ExamListResponse,
ExamSummaryStats,
MistakeListResponse
} from "@/lib/types";
export default async function DashboardPage() {
const currentUser = await requireCurrentUser();
const [exams, summary, mistakes, stats] = await Promise.all([
serverApi<ExamListResponse>("/exams/?skip=0&limit=5"),
serverApi<ExamSummaryStats>("/exams/summary"),
serverApi<MistakeListResponse>("/mistakes/?skip=0&limit=1"),
currentUser.is_admin
? serverApi<AdminStatisticsResponse>("/admin/statistics")
: Promise.resolve(null)
]);
return (
<div className="space-y-8">
<PageHeader eyebrow="Dashboard" title={`你好,${currentUser.username}`} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={FolderOpen}
label="题库"
value={String(summary.total_exams)}
detail={`${summary.ready_exams} 就绪 / ${summary.processing_exams} 处理中`}
/>
<StatCard
icon={BookOpen}
label="题目"
value={String(summary.total_questions)}
detail={`已完成 ${summary.completed_questions}`}
/>
<StatCard
icon={TriangleAlert}
label="错题"
value={String(mistakes.total)}
detail={mistakes.total > 0 ? "待复习" : "暂无错题"}
/>
<StatCard
icon={Shield}
label="角色"
value={currentUser.is_admin ? "管理员" : "用户"}
detail={currentUser.is_admin && stats ? `全站 ${stats.users.total} 用户` : undefined}
/>
</div>
<Card className="border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{exams.exams.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{exams.exams.map((exam) => (
<tr key={exam.id} className="border-t border-slate-200">
<td className="px-4 py-3">
<Link className="font-medium text-slate-900 hover:underline" href={`/exams/${exam.id}`}>
{exam.title}
</Link>
</td>
<td className="px-4 py-3">
<StatusBadge status={exam.status} />
</td>
<td className="px-4 py-3 text-slate-600">
{exam.current_index}/{exam.total_questions}
</td>
<td className="px-4 py-3 text-slate-600">
{formatRelativeTime(exam.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { ExamDetailClient } from "@/components/exams/exam-detail-client";
import { PageHeader } from "@/components/app-shell/page-header";
import { serverApi } from "@/lib/api/server";
import { ExamSummary } from "@/lib/types";
export default async function ExamDetailPage({
params
}: {
params: { examId: string };
}) {
const exam = await serverApi<ExamSummary>(`/exams/${params.examId}`);
return (
<div className="space-y-8">
<PageHeader eyebrow={`Exam #${params.examId}`} title="题库详情" />
<ExamDetailClient initialExam={exam} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { ExamsPageClient } from "@/components/exams/exams-page-client";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { ExamListResponse } from "@/lib/types";
export default async function ExamsPage({
searchParams
}: {
searchParams?: { page?: string | string[] };
}) {
const page = parsePositiveInt(searchParams?.page);
const data = await serverApi<ExamListResponse>(
`/exams/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}`
);
return (
<div className="space-y-8">
<PageHeader eyebrow="Exams" title="题库" />
<ExamsPageClient
initialExams={data.exams}
initialTotal={data.total}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { AppSidebar } from "@/components/app-shell/app-sidebar";
import { LogoutButton } from "@/components/app-shell/logout-button";
import { requireCurrentUser } from "@/lib/auth/guards";
export default async function AppLayout({
children
}: {
children: React.ReactNode;
}) {
const currentUser = await requireCurrentUser();
return (
<div className="min-h-screen xl:flex">
<AppSidebar isAdmin={currentUser.is_admin} />
<div className="min-h-screen flex-1">
<div className="flex items-center justify-between border-b border-slate-200/80 bg-white/70 px-6 py-4 backdrop-blur">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">QQuiz</p>
<p className="text-sm text-slate-600">
{currentUser.username} · {currentUser.is_admin ? "管理员" : "普通用户"}
</p>
</div>
<LogoutButton />
</div>
<main className="container py-8">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { MistakePracticeClient } from "@/components/practice/mistake-practice-client";
export default function MistakeQuizPage() {
return (
<div className="space-y-8">
<PageHeader eyebrow="Mistake Practice" title="错题练习" />
<MistakePracticeClient />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { MistakeListClient } from "@/components/mistakes/mistake-list-client";
import { PageHeader } from "@/components/app-shell/page-header";
import { serverApi } from "@/lib/api/server";
import { DEFAULT_PAGE_SIZE, getOffset, parsePositiveInt } from "@/lib/pagination";
import { MistakeListResponse } from "@/lib/types";
export default async function MistakesPage({
searchParams
}: {
searchParams?: { page?: string | string[] };
}) {
const page = parsePositiveInt(searchParams?.page);
const data = await serverApi<MistakeListResponse>(
`/mistakes/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}`
);
return (
<div className="space-y-8">
<PageHeader eyebrow="Mistakes" title="错题" />
<MistakeListClient
initialMistakes={data.mistakes}
initialTotal={data.total}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
/>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { QuestionList } from "@/components/questions/question-list";
import { serverApi } from "@/lib/api/server";
import {
DEFAULT_PAGE_SIZE,
getOffset,
parseOptionalPositiveInt,
parsePositiveInt
} from "@/lib/pagination";
import { QuestionListResponse } from "@/lib/types";
export default async function QuestionsPage({
searchParams
}: {
searchParams?: {
page?: string | string[];
examId?: string | string[];
};
}) {
const page = parsePositiveInt(searchParams?.page);
const examId = parseOptionalPositiveInt(searchParams?.examId);
const examFilter = examId ? `&exam_id=${examId}` : "";
const data = await serverApi<QuestionListResponse>(
`/questions/?skip=${getOffset(page, DEFAULT_PAGE_SIZE)}&limit=${DEFAULT_PAGE_SIZE}${examFilter}`
);
return (
<div className="space-y-8">
<PageHeader
eyebrow="Questions"
title="题目"
description={examId ? `当前仅显示题库 #${examId} 的题目。` : undefined}
/>
<QuestionList
examId={examId}
page={page}
pageSize={DEFAULT_PAGE_SIZE}
questions={data.questions}
total={data.total}
/>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { PageHeader } from "@/components/app-shell/page-header";
import { QuizPlayerClient } from "@/components/practice/quiz-player-client";
export default function QuizPage({
params
}: {
params: { examId: string };
}) {
return (
<div className="space-y-8">
<PageHeader eyebrow={`Quiz #${params.examId}`} title="刷题" />
<QuizPlayerClient examId={params.examId} />
</div>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ArrowRight, ShieldCheck } from "lucide-react";
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";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ username, password })
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.detail || "登录失败");
}
toast.success("登录成功");
router.push(searchParams.get("next") || "/dashboard");
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "登录失败");
} finally {
setLoading(false);
}
}
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden p-6">
<div className="absolute inset-0 bg-brand-grid bg-[size:34px_34px] opacity-40" />
<div className="relative grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_480px]">
<Card className="hidden border-slate-900/10 bg-slate-950 text-white lg:block">
<CardHeader className="pb-4">
<div className="flex items-center gap-3 text-sm uppercase tracking-[0.2em] text-slate-300">
<ShieldCheck className="h-4 w-4" />
QQuiz Web
</div>
<CardTitle className="text-4xl leading-tight"></CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm leading-7 text-slate-300">
<p>使</p>
</CardContent>
</Card>
<Card className="border-white/80 bg-white/92">
<CardHeader>
<CardTitle className="text-3xl"></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="请输入用户名"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
required
/>
</div>
<Button className="w-full" disabled={loading} type="submit">
{loading ? "登录中..." : "登录"}
<ArrowRight className="h-4 w-4" />
</Button>
<p className="text-sm text-slate-600">
<Link className="ml-2 font-medium text-primary underline-offset-4 hover:underline" href="/register">
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ArrowRight } from "lucide-react";
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";
export default function RegisterPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
try {
const response = await fetch("/api/proxy/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ username, password })
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.detail || "注册失败");
}
toast.success("注册成功,请登录");
router.push("/login");
} catch (error) {
toast.error(error instanceof Error ? error.message : "注册失败");
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center p-6">
<Card className="w-full max-w-lg border-white/80 bg-white/92">
<CardHeader>
<CardTitle className="text-3xl"></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="3-50 位字母、数字、_ 或 -"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
autoComplete="new-password"
type="password"
minLength={6}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 6 位"
required
/>
</div>
<Button className="w-full" disabled={loading} type="submit">
{loading ? "提交中..." : "注册"}
<ArrowRight className="h-4 w-4" />
</Button>
<p className="text-sm text-slate-600">
<Link className="ml-2 font-medium text-primary underline-offset-4 hover:underline" href="/login">
</Link>
</p>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,17 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/api/config";
async function clearSession() {
cookies().delete(SESSION_COOKIE_NAME);
return NextResponse.json({ ok: true });
}
export async function POST() {
return clearSession();
}
export async function GET() {
return clearSession();
}

View File

@@ -0,0 +1,30 @@
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,42 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
export async function GET(
_request: NextRequest,
{ params }: { params: { examId: string } }
) {
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ detail: "Unauthorized" }, { status: 401 });
}
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"
});
if (!response.ok || !response.body) {
const payload = await response.text();
return new NextResponse(payload || "Failed to open exam progress stream", {
status: response.status
});
}
return new NextResponse(response.body, {
status: response.status,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive"
}
});
}

View File

@@ -0,0 +1,82 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
async function proxyRequest(
request: NextRequest,
params: { path: string[] }
) {
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
const requestPath = params.path.join("/");
const target = `${buildBackendUrl(`/${requestPath}`)}${request.nextUrl.search}`;
const headers = new Headers();
const contentType = request.headers.get("content-type");
if (contentType) {
headers.set("Content-Type", contentType);
}
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const method = request.method;
const init: RequestInit = {
method,
headers,
cache: "no-store"
};
if (!["GET", "HEAD"].includes(method)) {
init.body = await request.arrayBuffer();
}
const response = await fetch(target, init);
const responseHeaders = new Headers(response.headers);
responseHeaders.delete("content-encoding");
responseHeaders.delete("content-length");
responseHeaders.delete("transfer-encoding");
return new NextResponse(response.body, {
status: response.status,
headers: responseHeaders
});
}
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}
export async function POST(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}
export async function PUT(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxyRequest(request, params);
}

47
web/src/app/globals.css Normal file
View File

@@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 25% 98%;
--foreground: 220 35% 12%;
--card: 0 0% 100%;
--card-foreground: 220 35% 12%;
--primary: 214 78% 34%;
--primary-foreground: 210 40% 98%;
--secondary: 210 24% 94%;
--secondary-foreground: 220 35% 18%;
--muted: 210 20% 96%;
--muted-foreground: 220 12% 42%;
--accent: 38 85% 92%;
--accent-foreground: 220 35% 18%;
--destructive: 0 76% 52%;
--destructive-foreground: 210 40% 98%;
--success: 154 63% 35%;
--success-foreground: 155 80% 96%;
--warning: 32 88% 45%;
--warning-foreground: 36 100% 96%;
--border: 214 24% 88%;
--input: 214 24% 88%;
--ring: 214 78% 34%;
--radius: 1.25rem;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
background-image:
radial-gradient(circle at top left, rgba(99, 142, 214, 0.1), transparent 22%),
linear-gradient(180deg, rgba(250, 252, 255, 0.98), rgba(245, 247, 250, 0.98));
font-family:
"Space Grotesk",
"Noto Sans SC",
"PingFang SC",
"Microsoft YaHei",
sans-serif;
}
}

27
web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Toaster } from "sonner";
import { QueryProvider } from "@/components/providers/query-provider";
import "@/app/globals.css";
export const metadata: Metadata = {
title: "QQuiz Web",
description: "QQuiz Next.js frontend migration scaffold"
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body>
<QueryProvider>
{children}
<Toaster richColors position="top-right" />
</QueryProvider>
</body>
</html>
);
}

7
web/src/app/loading.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function Loading() {
return (
<main className="flex min-h-screen items-center justify-center">
<div className="rounded-full border-4 border-slate-200 border-t-slate-950 p-6 animate-spin" />
</main>
);
}

24
web/src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function NotFound() {
return (
<main className="flex min-h-screen items-center justify-center p-6">
<Card className="max-w-lg border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm leading-6 text-slate-600">
Next.js 访
</p>
<Button asChild>
<Link href="/dashboard"></Link>
</Button>
</CardContent>
</Card>
</main>
);
}

7
web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { readSessionToken } from "@/lib/auth/session";
export default function IndexPage() {
redirect(readSessionToken() ? "/dashboard" : "/login");
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import { Loader2, Save } from "lucide-react";
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 { browserApi } from "@/lib/api/browser";
import { SystemConfigResponse } from "@/lib/types";
export function SettingsPanel({
initialConfig
}: {
initialConfig: SystemConfigResponse;
}) {
const [config, setConfig] = useState(initialConfig);
const [saving, setSaving] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
try {
const payload = await browserApi<SystemConfigResponse>("/admin/config", {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(config)
});
setConfig(payload);
toast.success("设置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
return (
<form className="grid gap-6 xl:grid-cols-2" onSubmit={handleSubmit}>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<label className="flex items-center justify-between gap-4 text-sm text-slate-700">
<span></span>
<input
checked={config.allow_registration}
className="h-4 w-4"
onChange={(event) =>
setConfig((current) => ({
...current,
allow_registration: event.target.checked
}))
}
type="checkbox"
/>
</label>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">MB</label>
<Input
type="number"
value={config.max_upload_size_mb}
onChange={(event) =>
setConfig((current) => ({
...current,
max_upload_size_mb: Number(event.target.value || 0)
}))
}
min={1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700"></label>
<Input
type="number"
value={config.max_daily_uploads}
onChange={(event) =>
setConfig((current) => ({
...current,
max_daily_uploads: Number(event.target.value || 0)
}))
}
min={1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">AI </label>
<select
className="flex h-11 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm"
value={config.ai_provider}
onChange={(event) =>
setConfig((current) => ({
...current,
ai_provider: event.target.value
}))
}
>
<option value="gemini">Gemini</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="qwen">Qwen</option>
</select>
</div>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI Base URL</label>
<Input
value={config.openai_base_url || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_base_url: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI API Key</label>
<Input
type="password"
value={config.openai_api_key || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_api_key: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Gemini </label>
<Input
value={config.gemini_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, gemini_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">OpenAI </label>
<Input
value={config.openai_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, openai_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Anthropic </label>
<Input
value={config.anthropic_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, anthropic_model: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Qwen </label>
<Input
value={config.qwen_model || ""}
onChange={(event) =>
setConfig((current) => ({ ...current, qwen_model: event.target.value }))
}
/>
</div>
</CardContent>
</Card>
<div className="xl:col-span-2">
<Button disabled={saving} type="submit">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,358 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Search, Shield, Trash2, UserPlus } from "lucide-react";
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 { PaginationControls } from "@/components/ui/pagination-controls";
import { browserApi } from "@/lib/api/browser";
import { formatDate } from "@/lib/formatters";
import { AdminUserSummary } from "@/lib/types";
type EditingState = {
id: number | null;
username: string;
password: string;
isAdmin: boolean;
};
export function UserManagementPanel({
initialPage,
initialSearch,
initialUsers,
initialTotal,
pageSize
}: {
initialPage: number;
initialSearch: string;
initialUsers: AdminUserSummary[];
initialTotal: number;
pageSize: number;
}) {
const router = useRouter();
const [search, setSearch] = useState(initialSearch);
const [users, setUsers] = useState(initialUsers);
const [total, setTotal] = useState(initialTotal);
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [editing, setEditing] = useState<EditingState>({
id: null,
username: "",
password: "",
isAdmin: false
});
const isCreateMode = editing.id === null;
const title = isCreateMode ? "创建用户" : "编辑用户";
const activeAdminCount = useMemo(
() => users.filter((user) => user.is_admin).length,
[users]
);
useEffect(() => {
setSearch(initialSearch);
setUsers(initialUsers);
setTotal(initialTotal);
}, [initialSearch, initialUsers, initialTotal]);
function buildAdminUrl(nextSearch: string, nextPage: number) {
const params = new URLSearchParams(window.location.search);
const normalizedSearch = nextSearch.trim();
if (nextPage <= 1) {
params.delete("page");
} else {
params.set("page", String(nextPage));
}
if (normalizedSearch) {
params.set("search", normalizedSearch);
} else {
params.delete("search");
}
const query = params.toString();
return query ? `/admin?${query}` : "/admin";
}
function startCreate() {
setEditing({
id: null,
username: "",
password: "",
isAdmin: false
});
}
function startEdit(user: AdminUserSummary) {
setEditing({
id: user.id,
username: user.username,
password: "",
isAdmin: user.is_admin
});
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
try {
if (isCreateMode) {
await browserApi("/admin/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
username: editing.username,
password: editing.password,
is_admin: editing.isAdmin
})
});
toast.success("用户已创建");
} else {
const updatePayload: Record<string, unknown> = {
username: editing.username,
is_admin: editing.isAdmin
};
if (editing.password) {
updatePayload.password = editing.password;
}
await browserApi(`/admin/users/${editing.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(updatePayload)
});
toast.success("用户已更新");
}
startCreate();
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSubmitting(false);
}
}
async function handleDelete(user: AdminUserSummary) {
if (!window.confirm(`确认删除用户 ${user.username}`)) {
return;
}
setDeletingId(user.id);
try {
await browserApi(`/admin/users/${user.id}`, {
method: "DELETE"
});
toast.success("用户已删除");
if (editing.id === user.id) {
startCreate();
}
if (users.length === 1 && initialPage > 1) {
router.push(buildAdminUrl(search, initialPage - 1));
} else {
router.refresh();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
async function handleResetPassword(user: AdminUserSummary) {
const nextPassword = window.prompt(`${user.username} 设置新密码`, "");
if (!nextPassword) {
return;
}
try {
await browserApi(`/admin/users/${user.id}/reset-password`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
new_password: nextPassword
})
});
toast.success("密码已重置");
} catch (error) {
toast.error(error instanceof Error ? error.message : "重置失败");
}
}
function handleSearch(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
router.push(buildAdminUrl(search, 1));
}
return (
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleSubmit}>
<Input
placeholder="用户名"
value={editing.username}
onChange={(event) =>
setEditing((current) => ({ ...current, username: event.target.value }))
}
required
/>
<Input
type="password"
placeholder={isCreateMode ? "密码" : "留空则不修改密码"}
value={editing.password}
onChange={(event) =>
setEditing((current) => ({ ...current, password: event.target.value }))
}
required={isCreateMode}
minLength={6}
/>
<label className="flex items-center gap-3 text-sm text-slate-700">
<input
checked={editing.isAdmin}
className="h-4 w-4 rounded border-slate-300"
onChange={(event) =>
setEditing((current) => ({ ...current, isAdmin: event.target.checked }))
}
type="checkbox"
/>
</label>
<div className="flex gap-2">
<Button className="flex-1" disabled={submitting} type="submit">
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isCreateMode ? (
<UserPlus className="h-4 w-4" />
) : (
<Shield className="h-4 w-4" />
)}
{isCreateMode ? "创建" : "保存"}
</Button>
{!isCreateMode ? (
<Button onClick={startCreate} type="button" variant="outline">
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="space-y-4">
<div className="flex items-center justify-between gap-3">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">
{total} / {activeAdminCount}
</div>
</div>
<form className="flex flex-col gap-3 md:flex-row" onSubmit={handleSearch}>
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="pl-9"
placeholder="搜索用户名"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<Button type="submit" variant="outline">
<Search className="h-4 w-4" />
</Button>
</form>
</CardHeader>
<CardContent>
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t border-slate-200">
<td className="px-4 py-3 font-medium text-slate-900">{user.username}</td>
<td className="px-4 py-3 text-slate-600">
{user.is_admin ? "管理员" : "普通用户"}
</td>
<td className="px-4 py-3 text-slate-600">{user.exam_count}</td>
<td className="px-4 py-3 text-slate-600">{user.mistake_count}</td>
<td className="px-4 py-3 text-slate-600">{formatDate(user.created_at)}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button onClick={() => startEdit(user)} size="sm" type="button" variant="outline">
</Button>
<Button
onClick={() => handleResetPassword(user)}
size="sm"
type="button"
variant="outline"
>
</Button>
<Button
aria-label={`删除 ${user.username}`}
disabled={deletingId === user.id}
onClick={() => handleDelete(user)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === user.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
{users.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-slate-500" colSpan={6}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={initialPage}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { BookMarked, LayoutDashboard, Settings, Shield, SquareStack, Target, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const baseNavigation = [
{ href: "/dashboard", label: "总览", icon: LayoutDashboard },
{ href: "/exams", label: "题库", icon: SquareStack },
{ href: "/questions", label: "题目", icon: BookMarked },
{ href: "/mistakes", label: "错题", icon: XCircle },
{ href: "/mistake-quiz", label: "错题练习", icon: Target }
];
const adminNavigation = [
{ href: "/admin", label: "管理", icon: Shield },
{ href: "/admin/settings", label: "系统设置", icon: Settings }
];
export function AppSidebar({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const navigation = isAdmin
? [...baseNavigation, ...adminNavigation]
: baseNavigation;
const activeHref =
navigation
.slice()
.sort((a, b) => b.href.length - a.href.length)
.find(
(item) =>
pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(`${item.href}/`))
)?.href || "";
return (
<aside className="hidden h-screen w-[280px] shrink-0 flex-col border-r border-slate-200 bg-white/80 px-5 py-6 backdrop-blur xl:flex">
<div className="space-y-4">
<Badge variant="outline" className="w-fit border-slate-300 text-slate-600">
QQuiz Web
</Badge>
<div>
<h2 className="text-xl font-semibold text-slate-950">QQuiz</h2>
<p className="mt-2 text-sm leading-6 text-slate-600"></p>
</div>
</div>
<Separator className="my-6" />
<nav className="space-y-2">
{navigation.map((item) => {
const Icon = item.icon;
const active = item.href === activeHref;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-medium transition-colors",
active
? "bg-primary text-white shadow-sm"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950"
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,65 @@
import { ArrowRight, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { PageHeader } from "@/components/app-shell/page-header";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
export function FeaturePlaceholder({
eyebrow,
title,
description,
bullets,
ctaHref = "/dashboard",
ctaLabel = "返回首页"
}: {
eyebrow: string;
title: string;
description?: string;
bullets: string[];
ctaHref?: string;
ctaLabel?: string;
}) {
return (
<div className="space-y-8">
<PageHeader eyebrow={eyebrow} title={title} description={description} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_280px]">
<Card className="border-slate-200/70 bg-white/90">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{bullets.map((bullet) => (
<div key={bullet} className="flex items-start gap-3 rounded-2xl bg-slate-50 p-4">
<CheckCircle2 className="mt-0.5 h-5 w-5 text-emerald-600" />
<p className="text-sm leading-6 text-slate-700">{bullet}</p>
</div>
))}
</CardContent>
</Card>
<Card className="border-slate-900/10 bg-slate-950 text-white">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<Button asChild variant="secondary" className="w-full bg-white text-slate-950">
<Link href={ctaHref}>
{ctaLabel}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
const response = await fetch("/api/auth/logout", {
method: "POST"
});
if (!response.ok) {
toast.error("退出失败");
return;
}
toast.success("已退出登录");
router.push("/login");
router.refresh();
}
return (
<Button onClick={handleLogout} variant="outline">
<LogOut className="h-4 w-4" />
退
</Button>
);
}

View File

@@ -0,0 +1,27 @@
import { Badge } from "@/components/ui/badge";
export function PageHeader({
eyebrow,
title,
description
}: {
eyebrow?: string;
title: string;
description?: string;
}) {
return (
<div className="space-y-4">
{eyebrow ? <Badge variant="outline">{eyebrow}</Badge> : null}
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-slate-950 md:text-4xl">
{title}
</h1>
{description ? (
<p className="max-w-3xl text-sm leading-7 text-slate-600 md:text-base">
{description}
</p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { LucideIcon } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
export function StatCard({
icon: Icon,
label,
value,
detail
}: {
icon: LucideIcon;
label: string;
value: string;
detail?: string;
}) {
return (
<Card className="border-white/70 bg-white/90">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl bg-slate-950 p-3 text-white">
<Icon className="h-5 w-5" />
</div>
<div>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</div>
</div>
</CardHeader>
{detail ? <CardContent className="text-sm text-slate-600">{detail}</CardContent> : null}
</Card>
);
}

View File

@@ -0,0 +1,15 @@
import { Badge } from "@/components/ui/badge";
import { getExamStatusLabel } from "@/lib/formatters";
export function StatusBadge({ status }: { status: string }) {
const variant =
status === "ready"
? "success"
: status === "failed"
? "destructive"
: status === "processing"
? "warning"
: "outline";
return <Badge variant={variant}>{getExamStatusLabel(status)}</Badge>;
}

View File

@@ -0,0 +1,232 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircle, FileText, Loader2, Play, RefreshCw, Upload } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/app-shell/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { browserApi } from "@/lib/api/browser";
import { formatDate } from "@/lib/formatters";
import { ExamSummary, ExamUploadResponse, ProgressEvent } from "@/lib/types";
export function ExamDetailClient({
initialExam
}: {
initialExam: ExamSummary;
}) {
const router = useRouter();
const eventSourceRef = useRef<EventSource | null>(null);
const [exam, setExam] = useState(initialExam);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState<ProgressEvent | null>(null);
const isProcessing = exam.status === "processing";
useEffect(() => {
if (!isProcessing) {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
return;
}
const source = new EventSource(`/api/exams/${exam.id}/progress`);
eventSourceRef.current = source;
source.onmessage = (event) => {
const payload = JSON.parse(event.data) as ProgressEvent;
setProgress(payload);
if (payload.status === "completed") {
toast.success(payload.message);
source.close();
eventSourceRef.current = null;
reloadExam();
}
if (payload.status === "failed") {
toast.error(payload.message);
source.close();
eventSourceRef.current = null;
reloadExam();
}
};
source.onerror = () => {
source.close();
eventSourceRef.current = null;
};
return () => {
source.close();
eventSourceRef.current = null;
};
}, [isProcessing, exam.id]);
async function reloadExam() {
try {
const payload = await browserApi<ExamSummary>(`/exams/${exam.id}`, {
method: "GET"
});
setExam(payload);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "刷新失败");
}
}
async function handleUpload(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) {
toast.error("请选择文件");
return;
}
const formData = new FormData();
formData.append("file", selectedFile);
setUploading(true);
try {
const payload = await browserApi<ExamUploadResponse>(`/exams/${exam.id}/append`, {
method: "POST",
body: formData
});
setExam((current) => ({ ...current, status: payload.status as ExamSummary["status"] }));
setProgress(null);
setSelectedFile(null);
toast.success("文档已提交");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
}
const progressValue = useMemo(() => {
if (isProcessing) {
return Math.round(Number(progress?.progress || 0));
}
if (exam.total_questions <= 0) {
return 0;
}
return Math.round((exam.current_index / exam.total_questions) * 100);
}, [exam.current_index, exam.total_questions, isProcessing, progress]);
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle className="text-2xl">{exam.title}</CardTitle>
<StatusBadge status={exam.status} />
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href={`/questions?examId=${exam.id}`}></Link>
</Button>
{exam.total_questions > 0 ? (
<Button asChild>
<Link href={`/quiz/${exam.id}`}></Link>
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{exam.total_questions}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{exam.current_index}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{Math.max(0, exam.total_questions - exam.current_index)}
</div>
</div>
<div className="rounded-2xl bg-slate-50 p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-2xl font-semibold text-slate-900">
{progressValue}%
</div>
</div>
</div>
<div className="space-y-2">
<div className="h-3 w-full overflow-hidden rounded-full bg-slate-200">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progressValue}%` }}
/>
</div>
{progress ? (
<div className="text-sm text-slate-600">{progress.message}</div>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2 text-sm text-slate-600">
<div>{formatDate(exam.created_at)}</div>
<div>{formatDate(exam.updated_at)}</div>
</div>
{exam.status === "failed" ? (
<div className="flex items-start gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-5 w-5" />
</div>
) : null}
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleUpload}>
<Input
type="file"
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
onChange={(event) => setSelectedFile(event.target.files?.[0] || null)}
required
/>
<Button className="w-full" disabled={uploading || isProcessing} type="submit">
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isProcessing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{isProcessing ? "处理中" : "上传"}
</Button>
</form>
<div className="mt-6 space-y-3 text-sm text-slate-600">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
TXT / PDF / DOC / DOCX / XLSX / XLS
</div>
<div></div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { StatusBadge } from "@/components/app-shell/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { PaginationControls } from "@/components/ui/pagination-controls";
import { browserApi } from "@/lib/api/browser";
import { formatDate, formatRelativeTime } from "@/lib/formatters";
import { ExamSummary } from "@/lib/types";
export function ExamsPageClient({
initialExams,
initialTotal,
page,
pageSize
}: {
initialExams: ExamSummary[];
initialTotal: number;
page: number;
pageSize: number;
}) {
const router = useRouter();
const [title, setTitle] = useState("");
const [file, setFile] = useState<File | null>(null);
const [creating, setCreating] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [exams, setExams] = useState(initialExams);
const [total, setTotal] = useState(initialTotal);
useEffect(() => {
setExams(initialExams);
setTotal(initialTotal);
}, [initialExams, initialTotal]);
function goToPage(targetPage: number) {
const params = new URLSearchParams(window.location.search);
if (targetPage <= 1) {
params.delete("page");
} else {
params.set("page", String(targetPage));
}
const query = params.toString();
router.push(query ? `/exams?${query}` : "/exams");
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!file) {
toast.error("请选择文件");
return;
}
const formData = new FormData();
formData.append("title", title);
formData.append("file", file);
formData.append("is_random", "false");
setCreating(true);
try {
const response = await browserApi<{ exam_id: number }>("/exams/create", {
method: "POST",
body: formData
});
toast.success("题库已创建");
setTitle("");
setFile(null);
router.push(`/exams/${response.exam_id}`);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建失败");
} finally {
setCreating(false);
}
}
async function handleDelete(examId: number) {
if (!window.confirm("确认删除这个题库?")) {
return;
}
setDeletingId(examId);
try {
await browserApi<void>(`/exams/${examId}`, {
method: "DELETE"
});
setExams((current) => current.filter((exam) => exam.id !== examId));
setTotal((current) => Math.max(0, current - 1));
toast.success("题库已删除");
if (exams.length === 1 && page > 1) {
goToPage(page - 1);
} else {
router.refresh();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
return (
<div className="grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={handleCreate}>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="题库名称"
required
/>
<Input
type="file"
accept=".txt,.pdf,.doc,.docx,.xlsx,.xls"
onChange={(event) => setFile(event.target.files?.[0] || null)}
required
/>
<Button className="w-full" disabled={creating} type="submit">
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{creating ? "创建中" : "创建"}
</Button>
</form>
</CardContent>
</Card>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">{total} </div>
</div>
</CardHeader>
<CardContent>
{exams.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{exams.map((exam) => (
<tr key={exam.id} className="border-t border-slate-200">
<td className="px-4 py-3">
<Link className="font-medium text-slate-900 hover:underline" href={`/exams/${exam.id}`}>
{exam.title}
</Link>
<div className="mt-1 text-xs text-slate-500">{formatDate(exam.created_at)}</div>
</td>
<td className="px-4 py-3">
<StatusBadge status={exam.status} />
</td>
<td className="px-4 py-3 text-slate-600">
{exam.current_index}/{exam.total_questions}
</td>
<td className="px-4 py-3 text-slate-600">
{formatRelativeTime(exam.updated_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/exams/${exam.id}`}></Link>
</Button>
<Button
aria-label={`删除 ${exam.title}`}
disabled={deletingId === exam.id}
onClick={() => handleDelete(exam.id)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === exam.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PaginationControls } from "@/components/ui/pagination-controls";
import { browserApi } from "@/lib/api/browser";
import { formatDate, getQuestionTypeLabel } from "@/lib/formatters";
import { MistakeListResponse } from "@/lib/types";
type MistakeItem = MistakeListResponse["mistakes"][number];
export function MistakeListClient({
initialMistakes,
initialTotal,
page,
pageSize
}: {
initialMistakes: MistakeItem[];
initialTotal: number;
page: number;
pageSize: number;
}) {
const router = useRouter();
const [mistakes, setMistakes] = useState(initialMistakes);
const [total, setTotal] = useState(initialTotal);
const [deletingId, setDeletingId] = useState<number | null>(null);
useEffect(() => {
setMistakes(initialMistakes);
setTotal(initialTotal);
}, [initialMistakes, initialTotal]);
function goToPage(targetPage: number) {
const params = new URLSearchParams(window.location.search);
if (targetPage <= 1) {
params.delete("page");
} else {
params.set("page", String(targetPage));
}
const query = params.toString();
router.push(query ? `/mistakes?${query}` : "/mistakes");
}
async function handleDelete(mistake: MistakeItem) {
setDeletingId(mistake.id);
try {
await browserApi<void>(`/mistakes/${mistake.id}`, {
method: "DELETE"
});
setMistakes((current) => current.filter((item) => item.id !== mistake.id));
setTotal((current) => Math.max(0, current - 1));
toast.success("已移除");
if (mistakes.length === 1 && page > 1) {
goToPage(page - 1);
} else {
router.refresh();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
} finally {
setDeletingId(null);
}
}
return (
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<div className="text-sm text-slate-500">{total} </div>
</CardHeader>
<CardContent>
{mistakes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{mistakes.map((mistake) => (
<tr key={mistake.id} className="border-t border-slate-200">
<td className="px-4 py-3 text-slate-700">
<div className="line-clamp-2 max-w-3xl">{mistake.question.content}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{getQuestionTypeLabel(mistake.question.type)}
</td>
<td className="px-4 py-3 text-slate-600">
<div className="line-clamp-1 max-w-xs">{mistake.question.answer}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(mistake.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<Button
aria-label={`删除错题 ${mistake.id}`}
disabled={deletingId === mistake.id}
onClick={() => handleDelete(mistake)}
size="icon"
type="button"
variant="ghost"
>
{deletingId === mistake.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,263 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, ArrowRight, Check, Loader2, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { browserApi } from "@/lib/api/browser";
import { AnswerCheckResponse, MistakeListResponse } from "@/lib/types";
import { getQuestionTypeLabel } from "@/lib/formatters";
type MistakeItem = MistakeListResponse["mistakes"][number];
export function MistakePracticeClient() {
const router = useRouter();
const searchParams = useSearchParams();
const [mistakes, setMistakes] = useState<MistakeItem[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<AnswerCheckResponse | null>(null);
const [userAnswer, setUserAnswer] = useState("");
const [multipleAnswers, setMultipleAnswers] = useState<string[]>([]);
useEffect(() => {
void loadMistakes();
}, []);
async function loadMistakes() {
setLoading(true);
try {
const payload = await browserApi<MistakeListResponse>("/mistakes/?skip=0&limit=1000", {
method: "GET"
});
let nextMistakes = payload.mistakes;
if (searchParams.get("mode") === "random") {
nextMistakes = [...payload.mistakes].sort(() => Math.random() - 0.5);
}
nextMistakes = nextMistakes.map((item) => {
if (item.question.type === "judge" && (!item.question.options || item.question.options.length === 0)) {
item.question.options = ["A. 正确", "B. 错误"];
}
return item;
});
setMistakes(nextMistakes);
setCurrentIndex(0);
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}
const currentMistake = mistakes[currentIndex] || null;
const question = currentMistake?.question || null;
const progressText = useMemo(
() => (mistakes.length ? `${currentIndex + 1} / ${mistakes.length}` : "0 / 0"),
[currentIndex, mistakes.length]
);
async function handleSubmit() {
if (!question) {
return;
}
let answer = userAnswer;
if (question.type === "multiple") {
if (multipleAnswers.length === 0) {
toast.error("请至少选择一个选项");
return;
}
answer = [...multipleAnswers].sort().join("");
}
if (!answer.trim()) {
toast.error("请输入答案");
return;
}
setSubmitting(true);
try {
const payload = await browserApi<AnswerCheckResponse>("/questions/check", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
question_id: question.id,
user_answer: answer
})
});
setResult(payload);
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交失败");
} finally {
setSubmitting(false);
}
}
async function handleRemove() {
if (!currentMistake) {
return;
}
try {
await browserApi<void>(`/mistakes/${currentMistake.id}`, {
method: "DELETE"
});
const nextList = mistakes.filter((item) => item.id !== currentMistake.id);
setMistakes(nextList);
setCurrentIndex((current) => Math.max(0, Math.min(current, nextList.length - 1)));
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
toast.success("已移除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "移除失败");
}
}
function handleNext() {
if (currentIndex < mistakes.length - 1) {
setCurrentIndex((current) => current + 1);
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
return;
}
toast.success("已完成");
router.push("/mistakes");
}
if (loading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!question) {
return (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<Button onClick={() => router.push("/mistakes")} type="button" variant="outline">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="text-sm text-slate-600">{progressText}</div>
</div>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle>{question.content}</CardTitle>
<div className="text-sm text-slate-500">{getQuestionTypeLabel(question.type)}</div>
</div>
<Button onClick={handleRemove} size="sm" type="button" variant="outline">
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
{question.options?.length ? (
<div className="space-y-3">
{question.options.map((option) => {
const letter = option.charAt(0);
const selected =
question.type === "multiple"
? multipleAnswers.includes(letter)
: userAnswer === letter;
return (
<button
key={option}
className={`w-full rounded-2xl border px-4 py-3 text-left text-sm transition ${
selected
? "border-primary bg-blue-50 text-slate-950"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300"
}`}
disabled={Boolean(result)}
onClick={() => {
if (result) {
return;
}
if (question.type === "multiple") {
setMultipleAnswers((current) =>
current.includes(letter)
? current.filter((item) => item !== letter)
: [...current, letter]
);
} else {
setUserAnswer(letter);
}
}}
type="button"
>
{option}
</button>
);
})}
</div>
) : null}
{question.type === "short" ? (
<textarea
className="min-h-36 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none ring-0 focus:border-primary"
onChange={(event) => setUserAnswer(event.target.value)}
placeholder="输入答案"
value={userAnswer}
/>
) : null}
{!result ? (
<Button className="w-full" disabled={submitting} onClick={handleSubmit} type="button">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</Button>
) : (
<div className={`rounded-2xl border p-4 ${result.correct ? "border-emerald-200 bg-emerald-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center gap-2 font-medium">
{result.correct ? <Check className="h-4 w-4 text-emerald-600" /> : <X className="h-4 w-4 text-red-600" />}
{result.correct ? "回答正确" : "回答错误"}
</div>
{!result.correct ? (
<div className="mt-3 text-sm text-slate-700">
{result.correct_answer}
</div>
) : null}
{result.analysis ? (
<div className="mt-3 text-sm text-slate-600">{result.analysis}</div>
) : null}
{result.ai_feedback ? (
<div className="mt-3 text-sm text-slate-600">{result.ai_feedback}</div>
) : null}
<Button className="mt-4 w-full" onClick={handleNext} type="button">
{currentIndex < mistakes.length - 1 ? "下一题" : "完成"}
<ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,287 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, ArrowRight, BookmarkPlus, BookmarkX, Check, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { browserApi } from "@/lib/api/browser";
import { AnswerCheckResponse, ExamSummary, QuestionDetail } from "@/lib/types";
import { getQuestionTypeLabel } from "@/lib/formatters";
export function QuizPlayerClient({
examId
}: {
examId: string;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [exam, setExam] = useState<ExamSummary | null>(null);
const [question, setQuestion] = useState<QuestionDetail | null>(null);
const [result, setResult] = useState<AnswerCheckResponse | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [inMistakeBook, setInMistakeBook] = useState(false);
const [userAnswer, setUserAnswer] = useState("");
const [multipleAnswers, setMultipleAnswers] = useState<string[]>([]);
useEffect(() => {
void loadQuiz();
}, [examId]);
async function loadQuiz() {
setLoading(true);
try {
if (searchParams.get("reset") === "true") {
await browserApi(`/exams/${examId}/progress`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ current_index: 0 })
});
}
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"
})
]);
if (questionPayload.type === "judge" && (!questionPayload.options || questionPayload.options.length === 0)) {
questionPayload.options = ["A. 正确", "B. 错误"];
}
setExam(examPayload);
setQuestion(questionPayload);
setResult(null);
setUserAnswer("");
setMultipleAnswers([]);
setInMistakeBook(mistakesPayload.mistakes.some((item) => item.question_id === questionPayload.id));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}
const progressText = useMemo(() => {
if (!exam) {
return "";
}
return `${exam.current_index + 1} / ${exam.total_questions}`;
}, [exam]);
async function handleSubmit() {
if (!question) {
return;
}
let answer = userAnswer;
if (question.type === "multiple") {
if (multipleAnswers.length === 0) {
toast.error("请至少选择一个选项");
return;
}
answer = [...multipleAnswers].sort().join("");
}
if (!answer.trim()) {
toast.error("请输入答案");
return;
}
setSubmitting(true);
try {
const payload = await browserApi<AnswerCheckResponse>("/questions/check", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
question_id: question.id,
user_answer: answer
})
});
setResult(payload);
setInMistakeBook(!payload.correct || inMistakeBook);
} catch (error) {
toast.error(error instanceof Error ? error.message : "提交失败");
} finally {
setSubmitting(false);
}
}
async function handleNext() {
if (!exam) {
return;
}
try {
await browserApi(`/exams/${examId}/progress`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ current_index: exam.current_index + 1 })
});
await loadQuiz();
} catch (error) {
toast.error(error instanceof Error ? error.message : "跳转失败");
}
}
async function handleToggleMistake() {
if (!question) {
return;
}
try {
if (inMistakeBook) {
await browserApi(`/mistakes/question/${question.id}`, {
method: "DELETE"
});
setInMistakeBook(false);
} else {
await browserApi("/mistakes/add", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ question_id: question.id })
});
setInMistakeBook(true);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}
if (loading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!exam || !question) {
return (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-10 text-center text-sm text-slate-500">
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center justify-between">
<Button onClick={() => router.push(`/exams/${examId}`)} type="button" variant="outline">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="text-sm text-slate-600">{progressText}</div>
</div>
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-start justify-between">
<div className="space-y-2">
<CardTitle>{question.content}</CardTitle>
<div className="text-sm text-slate-500">{getQuestionTypeLabel(question.type)}</div>
</div>
<Button onClick={handleToggleMistake} size="sm" type="button" variant="outline">
{inMistakeBook ? <BookmarkX className="h-4 w-4" /> : <BookmarkPlus className="h-4 w-4" />}
{inMistakeBook ? "移除错题" : "加入错题"}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{question.options?.length ? (
<div className="space-y-3">
{question.options.map((option) => {
const letter = option.charAt(0);
const selected =
question.type === "multiple"
? multipleAnswers.includes(letter)
: userAnswer === letter;
return (
<button
key={option}
className={`w-full rounded-2xl border px-4 py-3 text-left text-sm transition ${
selected
? "border-primary bg-blue-50 text-slate-950"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300"
}`}
disabled={Boolean(result)}
onClick={() => {
if (result) {
return;
}
if (question.type === "multiple") {
setMultipleAnswers((current) =>
current.includes(letter)
? current.filter((item) => item !== letter)
: [...current, letter]
);
} else {
setUserAnswer(letter);
}
}}
type="button"
>
{option}
</button>
);
})}
</div>
) : null}
{question.type === "short" ? (
<textarea
className="min-h-36 w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none ring-0 focus:border-primary"
onChange={(event) => setUserAnswer(event.target.value)}
placeholder="输入答案"
value={userAnswer}
/>
) : null}
{!result ? (
<Button className="w-full" disabled={submitting} onClick={handleSubmit} type="button">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
</Button>
) : (
<div className={`rounded-2xl border p-4 ${result.correct ? "border-emerald-200 bg-emerald-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center gap-2 font-medium">
{result.correct ? <Check className="h-4 w-4 text-emerald-600" /> : <X className="h-4 w-4 text-red-600" />}
{result.correct ? "回答正确" : "回答错误"}
</div>
{!result.correct ? (
<div className="mt-3 text-sm text-slate-700">
{result.correct_answer}
</div>
) : null}
{result.analysis ? (
<div className="mt-3 text-sm text-slate-600">{result.analysis}</div>
) : null}
{result.ai_feedback ? (
<div className="mt-3 text-sm text-slate-600">{result.ai_feedback}</div>
) : null}
<Button className="mt-4 w-full" onClick={handleNext} type="button">
<ArrowRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 10_000
}
}
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,73 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PaginationControls } from "@/components/ui/pagination-controls";
import { getQuestionTypeLabel, formatDate } from "@/lib/formatters";
import { QuestionListItem } from "@/lib/types";
export function QuestionList({
examId,
page,
pageSize,
questions,
total
}: {
examId?: number;
page: number;
pageSize: number;
questions: QuestionListItem[];
total: number;
}) {
return (
<Card className="border-slate-200/70 bg-white/92">
<CardHeader className="flex flex-row items-center justify-between">
<div className="space-y-1">
<CardTitle></CardTitle>
{examId ? <div className="text-sm text-slate-500"> #{examId}</div> : null}
</div>
<div className="text-sm text-slate-500">{total} </div>
</CardHeader>
<CardContent>
{questions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-slate-200">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{questions.map((question) => (
<tr key={question.id} className="border-t border-slate-200">
<td className="px-4 py-3 text-slate-700">
<div className="line-clamp-2 max-w-3xl">{question.content}</div>
</td>
<td className="px-4 py-3 text-slate-600">
{getQuestionTypeLabel(question.type)}
</td>
<td className="px-4 py-3 text-slate-600">#{question.exam_id}</td>
<td className="px-4 py-3 text-slate-600">
{formatDate(question.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<PaginationControls
className="mt-4 rounded-2xl border border-slate-200"
page={page}
pageSize={pageSize}
total={total}
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,33 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary/12 text-primary",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "border-border text-foreground",
success: "border-transparent bg-success/15 text-success",
warning: "border-transparent bg-warning/20 text-warning",
destructive: "border-transparent bg-destructive/15 text-destructive"
}
},
defaultVariants: {
variant: "default"
}
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:brightness-110",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-border bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground"
},
size: {
default: "h-11 px-5 py-2",
sm: "h-9 px-4",
lg: "h-12 px-6",
icon: "h-10 w-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,68 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[28px] border border-border/60 bg-card/95 text-card-foreground shadow-panel backdrop-blur",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-xl font-semibold tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-2xl border border-input bg-background px-4 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function getVisiblePages(currentPage: number, totalPages: number) {
const pages = new Set<number>([1, totalPages, currentPage]);
for (let page = currentPage - 1; page <= currentPage + 1; page += 1) {
if (page > 1 && page < totalPages) {
pages.add(page);
}
}
return Array.from(pages).sort((a, b) => a - b);
}
function PageButton({
href,
children,
disabled,
active = false,
className
}: {
href: string;
children: ReactNode;
disabled?: boolean;
active?: boolean;
className?: string;
}) {
if (disabled) {
return (
<Button className={className} disabled size="sm" type="button" variant="outline">
{children}
</Button>
);
}
return (
<Button
asChild
className={className}
size="sm"
type="button"
variant={active ? "default" : "outline"}
>
<Link href={href}>{children}</Link>
</Button>
);
}
export function PaginationControls({
page,
pageSize,
total,
className
}: {
page: number;
pageSize: number;
total: number;
className?: string;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
if (total <= pageSize) {
return null;
}
const rangeStart = (currentPage - 1) * pageSize + 1;
const rangeEnd = Math.min(total, currentPage * pageSize);
const visiblePages = getVisiblePages(currentPage, totalPages);
function buildHref(targetPage: number) {
const params = new URLSearchParams(searchParams?.toString());
if (targetPage <= 1) {
params.delete("page");
} else {
params.set("page", String(targetPage));
}
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
return (
<div
className={cn(
"flex flex-col gap-3 border-t border-slate-200 px-4 py-4 text-sm text-slate-600 md:flex-row md:items-center md:justify-between",
className
)}
>
<div>
{rangeStart}-{rangeEnd} {total}
</div>
<div className="flex flex-wrap items-center gap-2">
<PageButton disabled={currentPage <= 1} href={buildHref(1)}>
<ChevronsLeft className="h-4 w-4" />
</PageButton>
<PageButton disabled={currentPage <= 1} href={buildHref(currentPage - 1)}>
<ChevronLeft className="h-4 w-4" />
</PageButton>
{visiblePages.map((visiblePage, index) => {
const previousPage = visiblePages[index - 1];
const showGap = previousPage && visiblePage - previousPage > 1;
return (
<div key={visiblePage} className="flex items-center gap-2">
{showGap ? <span className="px-1 text-slate-400">...</span> : null}
<PageButton
active={visiblePage === currentPage}
href={buildHref(visiblePage)}
>
{visiblePage}
</PageButton>
</div>
);
})}
<PageButton disabled={currentPage >= totalPages} href={buildHref(currentPage + 1)}>
<ChevronRight className="h-4 w-4" />
</PageButton>
<PageButton disabled={currentPage >= totalPages} href={buildHref(totalPages)}>
<ChevronsRight className="h-4 w-4" />
</PageButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,57 @@
import { buildProxyUrl } from "@/lib/api/config";
type BrowserApiOptions = Omit<RequestInit, "body"> & {
body?: BodyInit | null;
query?: Record<string, string | number | boolean | null | undefined>;
};
function buildSearchParams(
query?: BrowserApiOptions["query"]
): URLSearchParams | undefined {
if (!query) {
return undefined;
}
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.set(key, String(value));
}
});
return params;
}
export async function browserApi<T>(
path: string,
options: BrowserApiOptions = {}
): Promise<T> {
const { query, headers, ...init } = options;
const response = await fetch(buildProxyUrl(path, buildSearchParams(query)), {
...init,
headers: {
...(headers || {})
},
credentials: "same-origin",
cache: "no-store"
});
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);
}
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}

23
web/src/lib/api/config.ts Normal file
View File

@@ -0,0 +1,23 @@
export const SESSION_COOKIE_NAME = "access_token";
export function getApiBaseUrl() {
return process.env.API_BASE_URL || "http://localhost:8000";
}
export function buildBackendUrl(path: string) {
const trimmedBase = getApiBaseUrl().replace(/\/$/, "");
const normalizedPath = path.startsWith("/api/")
? path
: `/api/${path.replace(/^\//, "")}`;
return `${trimmedBase}${normalizedPath}`;
}
export function buildProxyUrl(path: string, search?: URLSearchParams) {
const normalizedPath = path.replace(/^\/+/, "");
const query = search?.toString();
return query
? `/api/proxy/${normalizedPath}?${query}`
: `/api/proxy/${normalizedPath}`;
}

50
web/src/lib/api/server.ts Normal file
View File

@@ -0,0 +1,50 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import {
SESSION_COOKIE_NAME,
buildBackendUrl
} from "@/lib/api/config";
type ServerApiOptions = RequestInit & {
next?: { revalidate?: number };
};
export async function serverApi<T>(
path: string,
init: ServerApiOptions = {}
): Promise<T> {
const token = cookies().get(SESSION_COOKIE_NAME)?.value;
const response = await fetch(buildBackendUrl(path), {
...init,
cache: init.cache || "no-store",
headers: {
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {})
}
});
if (response.status === 401) {
redirect("/login");
}
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);
}
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}

View File

@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { serverApi } from "@/lib/api/server";
import { AuthUser } from "@/lib/types";
export async function requireCurrentUser() {
try {
return await serverApi<AuthUser>("/auth/me");
} catch (_error) {
redirect("/login");
}
}
export async function requireAdminUser() {
const user = await requireCurrentUser();
if (!user.is_admin) {
redirect("/dashboard");
}
return user;
}

View File

@@ -0,0 +1,17 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE_NAME } from "@/lib/api/config";
export function readSessionToken() {
return cookies().get(SESSION_COOKIE_NAME)?.value || null;
}
export function requireSessionToken() {
const token = readSessionToken();
if (!token) {
redirect("/login");
}
return token;
}

50
web/src/lib/formatters.ts Normal file
View File

@@ -0,0 +1,50 @@
export function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
export function formatRelativeTime(value: string) {
const date = new Date(value);
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (days > 0) {
return `${days} 天前`;
}
if (hours > 0) {
return `${hours} 小时前`;
}
if (minutes > 0) {
return `${minutes} 分钟前`;
}
return "刚刚";
}
export function getExamStatusLabel(status: string) {
const map: Record<string, string> = {
pending: "等待中",
processing: "处理中",
ready: "就绪",
failed: "失败"
};
return map[status] || status;
}
export function getQuestionTypeLabel(type: string) {
const map: Record<string, string> = {
single: "单选",
multiple: "多选",
judge: "判断",
short: "简答"
};
return map[type] || type;
}

41
web/src/lib/pagination.ts Normal file
View File

@@ -0,0 +1,41 @@
export const DEFAULT_PAGE_SIZE = 20;
export type SearchParamValue = string | string[] | undefined;
export function parsePositiveInt(
value: SearchParamValue,
fallback = 1
): number {
const raw = Array.isArray(value) ? value[0] : value;
const parsed = Number(raw);
if (!raw || !Number.isFinite(parsed) || parsed < 1) {
return fallback;
}
return Math.floor(parsed);
}
export function parseOptionalPositiveInt(
value: SearchParamValue
): number | undefined {
const raw = Array.isArray(value) ? value[0] : value;
if (!raw) {
return undefined;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 1) {
return undefined;
}
return Math.floor(parsed);
}
export function getOffset(page: number, pageSize = DEFAULT_PAGE_SIZE) {
return Math.max(0, page - 1) * pageSize;
}
export function getTotalPages(total: number, pageSize = DEFAULT_PAGE_SIZE) {
return Math.max(1, Math.ceil(total / pageSize));
}

149
web/src/lib/types.ts Normal file
View File

@@ -0,0 +1,149 @@
export interface AuthUser {
id: number;
username: string;
is_admin: boolean;
created_at: string;
}
export interface ExamSummary {
id: number;
user_id: number;
title: string;
status: "pending" | "processing" | "ready" | "failed";
current_index: number;
total_questions: number;
created_at: string;
updated_at: string;
}
export interface ExamListResponse {
exams: ExamSummary[];
total: number;
}
export interface ExamSummaryStats {
total_exams: number;
total_questions: number;
completed_questions: number;
processing_exams: number;
ready_exams: number;
failed_exams: number;
}
export interface QuestionListItem {
id: number;
exam_id: number;
content: string;
type: "single" | "multiple" | "judge" | "short";
options?: string[] | null;
analysis?: string | null;
created_at: string;
}
export interface QuestionDetail extends QuestionListItem {}
export interface QuestionListResponse {
questions: QuestionListItem[];
total: number;
}
export interface AnswerCheckResponse {
correct: boolean;
user_answer: string;
correct_answer: string;
analysis?: string | null;
ai_score?: number | null;
ai_feedback?: string | null;
}
export interface ExamUploadResponse {
exam_id: number;
title: string;
status: string;
message: string;
}
export interface ProgressEvent {
exam_id: number;
status: string;
message: string;
progress: number;
total_chunks: number;
current_chunk: number;
questions_extracted: number;
questions_added: number;
duplicates_removed: number;
timestamp: string;
}
export interface MistakeListResponse {
mistakes: Array<{
id: number;
user_id: number;
question_id: number;
created_at: string;
question: {
id: number;
exam_id: number;
content: string;
type: "single" | "multiple" | "judge" | "short";
options?: string[] | null;
answer: string;
analysis?: string | null;
created_at: string;
};
}>;
total: number;
}
export interface AdminUserSummary extends AuthUser {
exam_count: number;
mistake_count: number;
}
export interface UserListResponse {
users: AdminUserSummary[];
total: number;
skip: number;
limit: number;
}
export interface AdminStatisticsResponse {
users: {
total: number;
admins: number;
regular_users: number;
};
exams: {
total: number;
today_uploads: number;
by_status: Record<string, number>;
upload_trend: Array<{ date: string; count: number }>;
};
questions: {
total: number;
by_type: Record<string, number>;
};
activity: {
today_active_users: number;
today_uploads: number;
};
}
export interface SystemConfigResponse {
allow_registration: boolean;
max_upload_size_mb: number;
max_daily_uploads: number;
ai_provider: string;
openai_api_key?: string | null;
openai_base_url?: string | null;
openai_model?: string | null;
anthropic_api_key?: string | null;
anthropic_model?: string | null;
qwen_api_key?: string | null;
qwen_base_url?: string | null;
qwen_model?: string | null;
gemini_api_key?: string | null;
gemini_base_url?: string | null;
gemini_model?: string | null;
}

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

31
web/src/middleware.ts Normal file
View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { SESSION_COOKIE_NAME } from "@/lib/api/config";
const protectedPrefixes = [
"/dashboard",
"/exams",
"/quiz",
"/mistakes",
"/mistake-quiz",
"/questions",
"/admin"
];
export function middleware(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
const { pathname } = request.nextUrl;
if (protectedPrefixes.some((prefix) => pathname.startsWith(prefix)) && !token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"]
};

72
web/tailwind.config.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { Config } from "tailwindcss";
import animate from "tailwindcss-animate";
const config: Config = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1.5rem",
screens: {
"2xl": "1400px"
}
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))"
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))"
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))"
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))"
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))"
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))"
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))"
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))"
}
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)"
},
boxShadow: {
panel: "0 24px 60px rgba(20, 32, 56, 0.14)"
},
backgroundImage: {
"brand-grid":
"linear-gradient(to right, rgba(17, 24, 39, 0.04) 1px, transparent 1px), linear-gradient(to bottom, rgba(17, 24, 39, 0.04) 1px, transparent 1px)"
}
}
},
plugins: [animate]
};
export default config;

25
web/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}