feat: rebuild CSP practice workflow, UX and automation
这个提交包含在:
@@ -1,36 +1,43 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Frontend (Next.js)
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
默认访问:`http://localhost:3000`
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
## 构建
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## 环境变量
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
- `NEXT_PUBLIC_API_BASE`:浏览器访问后端 API 的基地址。
|
||||
- 开发默认:`http://localhost:8080`
|
||||
- Docker/生产推荐:`/admin139`
|
||||
- `BACKEND_INTERNAL_URL`:Next.js 反向代理后端目标(服务端)
|
||||
- Docker 默认:`http://backend:8080`
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## 页面
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
- `/auth` 登录/注册
|
||||
- `/problems` 题库列表
|
||||
- `/problems/:id` 题目详情与提交
|
||||
- `/submissions` 提交列表
|
||||
- `/submissions/:id` 提交详情
|
||||
- `/wrong-book` 错题本
|
||||
- `/contests` 模拟竞赛列表
|
||||
- `/contests/:id` 比赛详情/报名/排行榜
|
||||
- `/kb` 知识库列表
|
||||
- `/kb/:slug` 文章详情
|
||||
- `/imports` 题库导入任务状态与结果
|
||||
- `/run` 在线 C++ 运行
|
||||
- `/me` 当前用户信息
|
||||
- `/leaderboard` 全站排行
|
||||
|
||||
@@ -6,8 +6,11 @@ const nextConfig: NextConfig = {
|
||||
async rewrites() {
|
||||
// Reverse proxy backend under a path prefix, so browser can access backend
|
||||
// with same-origin (no CORS): http://<host>:7888/admin139/...
|
||||
const backendInternal = process.env.BACKEND_INTERNAL_URL;
|
||||
if (!backendInternal) return [];
|
||||
const backendInternal =
|
||||
process.env.BACKEND_INTERNAL_URL ??
|
||||
(process.env.NODE_ENV === "development"
|
||||
? "http://127.0.0.1:8080"
|
||||
: "http://backend:8080");
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
3632
frontend/package-lock.json
自动生成的
3632
frontend/package-lock.json
自动生成的
文件差异内容过多而无法显示
加载差异
@@ -9,9 +9,18 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"swagger-ui-react": "^5.31.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const specUrl = useMemo(() => `${API_BASE}/api/openapi.json`, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-6 py-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold">API 文档(Swagger)</h1>
|
||||
<div className="rounded-xl border bg-white p-2">
|
||||
<SwaggerUI url={specUrl} docExpansion="list" defaultModelsExpandDepth={1} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createHash } from "crypto";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const CACHE_DIR = process.env.CSP_IMAGE_CACHE_DIR ?? "/tmp/csp-image-cache";
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
|
||||
return view.buffer.slice(
|
||||
view.byteOffset,
|
||||
view.byteOffset + view.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function pickExt(urlObj: URL, contentType: string): string {
|
||||
const fromPath = path.extname(urlObj.pathname || "").toLowerCase();
|
||||
if (fromPath && fromPath.length <= 10) return fromPath;
|
||||
|
||||
if (contentType.includes("image/png")) return ".png";
|
||||
if (contentType.includes("image/jpeg")) return ".jpg";
|
||||
if (contentType.includes("image/webp")) return ".webp";
|
||||
if (contentType.includes("image/gif")) return ".gif";
|
||||
if (contentType.includes("image/svg+xml")) return ".svg";
|
||||
return ".img";
|
||||
}
|
||||
|
||||
async function readCachedByKey(
|
||||
key: string
|
||||
): Promise<{ data: Uint8Array; contentType: string } | null> {
|
||||
try {
|
||||
const files = await fs.readdir(CACHE_DIR);
|
||||
const hit = files.find((name) => name.startsWith(`${key}.`));
|
||||
if (!hit) return null;
|
||||
const ext = path.extname(hit).toLowerCase();
|
||||
let contentType = "application/octet-stream";
|
||||
if (ext === ".png") contentType = "image/png";
|
||||
else if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
|
||||
else if (ext === ".webp") contentType = "image/webp";
|
||||
else if (ext === ".gif") contentType = "image/gif";
|
||||
else if (ext === ".svg") contentType = "image/svg+xml";
|
||||
const data = new Uint8Array(await fs.readFile(path.join(CACHE_DIR, hit)));
|
||||
return { data, contentType };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const raw = req.nextUrl.searchParams.get("url") ?? "";
|
||||
if (!raw) {
|
||||
return NextResponse.json({ ok: false, error: "missing url" }, { status: 400 });
|
||||
}
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(raw);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "invalid url" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||
return NextResponse.json({ ok: false, error: "only http/https allowed" }, { status: 400 });
|
||||
}
|
||||
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
|
||||
const key = createHash("sha1").update(target.toString()).digest("hex");
|
||||
const probe = await readCachedByKey(key);
|
||||
if (probe) {
|
||||
const body = new Blob([toArrayBuffer(probe.data)], { type: probe.contentType });
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": probe.contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 12000);
|
||||
|
||||
try {
|
||||
const resp = await fetch(target.toString(), {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "csp-platform-image-cache/1.0",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `fetch image failed: HTTP ${resp.status}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = (resp.headers.get("content-type") ?? "").toLowerCase();
|
||||
if (!contentType.startsWith("image/")) {
|
||||
return NextResponse.json({ ok: false, error: "url is not an image" }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await resp.arrayBuffer());
|
||||
if (data.length <= 0 || data.length > MAX_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "image is empty or too large" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const ext = pickExt(target, contentType);
|
||||
const finalFile = path.join(CACHE_DIR, `${key}${ext}`);
|
||||
await fs.writeFile(finalFile, data);
|
||||
|
||||
const body = new Blob([toArrayBuffer(data)], { type: contentType });
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `fetch image failed: ${String(e)}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { API_BASE, apiFetch } from "@/lib/api";
|
||||
import { saveToken } from "@/lib/auth";
|
||||
|
||||
type AuthOk = { ok: true; user_id: number; token: string; expires_at: number };
|
||||
type AuthErr = { ok: false; error: string };
|
||||
type AuthResp = AuthOk | AuthErr;
|
||||
|
||||
export default function AuthPage() {
|
||||
const apiBase = useMemo(
|
||||
() => process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080",
|
||||
[]
|
||||
);
|
||||
function passwordScore(password: string): { label: string; color: string } {
|
||||
if (password.length >= 12) return { label: "强", color: "text-emerald-600" };
|
||||
if (password.length >= 8) return { label: "中", color: "text-blue-600" };
|
||||
return { label: "弱", color: "text-orange-600" };
|
||||
}
|
||||
|
||||
const [mode, setMode] = useState<"register" | "login">("register");
|
||||
const [username, setUsername] = useState(
|
||||
process.env.NEXT_PUBLIC_TEST_USERNAME ?? ""
|
||||
);
|
||||
const [password, setPassword] = useState(
|
||||
process.env.NEXT_PUBLIC_TEST_PASSWORD ?? ""
|
||||
);
|
||||
export default function AuthPage() {
|
||||
const router = useRouter();
|
||||
const apiBase = useMemo(() => API_BASE, []);
|
||||
|
||||
const [mode, setMode] = useState<"register" | "login">("login");
|
||||
const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
|
||||
const [password, setPassword] = useState(process.env.NEXT_PUBLIC_TEST_PASSWORD ?? "");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resp, setResp] = useState<AuthResp | null>(null);
|
||||
|
||||
const usernameErr = username.trim().length < 3 ? "用户名至少 3 位" : "";
|
||||
const passwordErr = password.length < 6 ? "密码至少 6 位" : "";
|
||||
const confirmErr =
|
||||
mode === "register" && password !== confirmPassword ? "两次密码不一致" : "";
|
||||
|
||||
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
setResp(null);
|
||||
try {
|
||||
const r = await fetch(`${apiBase}/api/v1/auth/${mode}`, {
|
||||
const j = await apiFetch<AuthResp>(`/api/v1/auth/${mode}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ username: username.trim(), password }),
|
||||
});
|
||||
const j = (await r.json()) as AuthResp;
|
||||
setResp(j);
|
||||
if (j.ok) {
|
||||
saveToken(j.token);
|
||||
setTimeout(() => {
|
||||
router.push("/problems");
|
||||
}, 350);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setResp({ ok: false, error: String(e) });
|
||||
} finally {
|
||||
@@ -40,75 +59,140 @@ export default function AuthPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const strength = passwordScore(password);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<main className="mx-auto max-w-xl px-6 py-12">
|
||||
<h1 className="text-2xl font-semibold">{mode === "register" ? "注册" : "登录"}</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
API Base: <span className="font-mono">{apiBase}</span>
|
||||
</p>
|
||||
<main className="mx-auto max-w-4xl px-6 py-10">
|
||||
<div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
|
||||
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
|
||||
<h1 className="text-2xl font-semibold">欢迎回来,开始刷题</h1>
|
||||
<p className="mt-3 text-sm text-zinc-300">
|
||||
登录后可提交评测、保存草稿、查看错题本和个人进度。
|
||||
</p>
|
||||
<div className="mt-6 space-y-2 text-sm text-zinc-300">
|
||||
<p>• 题库按 CSP-J / CSP-S / NOIP 入门组织</p>
|
||||
<p>• 题目页支持本地草稿与试运行</p>
|
||||
<p>• 生成式题解会异步入库,支持多解法</p>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-zinc-400">
|
||||
API Base: <span className="font-mono">{apiBase}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<button
|
||||
className={`rounded-lg px-3 py-2 text-sm ${
|
||||
mode === "register" ? "bg-zinc-900 text-white" : "bg-white border"
|
||||
}`}
|
||||
onClick={() => setMode("register")}
|
||||
disabled={loading}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-lg px-3 py-2 text-sm ${
|
||||
mode === "login" ? "bg-zinc-900 text-white" : "bg-white border"
|
||||
}`}
|
||||
onClick={() => setMode("login")}
|
||||
disabled={loading}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
<section className="rounded-2xl border bg-white p-6">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-zinc-100 p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-3 py-2 ${
|
||||
mode === "login" ? "bg-white shadow-sm" : "text-zinc-600"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setResp(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-3 py-2 ${
|
||||
mode === "register" ? "bg-white shadow-sm" : "text-zinc-600"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
setResp(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-xl border bg-white p-5">
|
||||
<label className="block text-sm font-medium">用户名</label>
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border px-3 py-2"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="alice"
|
||||
/>
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">用户名</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="例如:csp_student"
|
||||
/>
|
||||
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>}
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block text-sm font-medium">密码(至少6位)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="mt-2 w-full rounded-lg border px-3 py-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="password123"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">密码</label>
|
||||
<span className={`text-xs ${strength.color}`}>强度:{strength.label}</span>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
{passwordErr && <p className="mt-1 text-xs text-red-600">{passwordErr}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mt-5 w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
onClick={submit}
|
||||
disabled={loading || !username || !password}
|
||||
>
|
||||
{loading ? "提交中..." : mode === "register" ? "注册" : "登录"}
|
||||
</button>
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">确认密码</label>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="mt-1 w-full rounded-lg border px-3 py-2"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="再输入一次密码"
|
||||
/>
|
||||
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 rounded-xl border bg-white p-5">
|
||||
<h2 className="text-sm font-medium">响应</h2>
|
||||
<pre className="mt-3 overflow-auto rounded-lg bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{JSON.stringify(resp, null, 2)}
|
||||
</pre>
|
||||
{resp && resp.ok && (
|
||||
<p className="mt-3 text-xs text-zinc-600">
|
||||
你可以把 token 用在后续需要登录的接口里:
|
||||
<span className="font-mono"> Authorization: Bearer {resp.token}</span>
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-xs text-zinc-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPassword}
|
||||
onChange={(e) => setShowPassword(e.target.checked)}
|
||||
/>
|
||||
显示密码
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="w-full rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
onClick={() => void submit()}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{loading ? "提交中..." : mode === "register" ? "注册并登录" : "登录"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{resp && (
|
||||
<div
|
||||
className={`mt-4 rounded-lg border px-3 py-2 text-sm ${
|
||||
resp.ok ? "border-emerald-300 bg-emerald-50 text-emerald-700" : "border-red-300 bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{resp.ok
|
||||
? "登录成功,正在跳转到题库..."
|
||||
: `操作失败:${resp.error}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-zinc-500">
|
||||
登录后 Token 自动保存在浏览器 localStorage,可直接前往
|
||||
<Link className="mx-1 underline" href="/problems">
|
||||
题库
|
||||
</Link>
|
||||
与
|
||||
<Link className="mx-1 underline" href="/me">
|
||||
我的
|
||||
</Link>
|
||||
页面。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
|
||||
type Contest = {
|
||||
id: number;
|
||||
title: string;
|
||||
starts_at: number;
|
||||
ends_at: number;
|
||||
rule_json: string;
|
||||
};
|
||||
|
||||
type Problem = {
|
||||
id: number;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
};
|
||||
|
||||
type LeaderboardRow = {
|
||||
user_id: number;
|
||||
username: string;
|
||||
solved: number;
|
||||
penalty_sec: number;
|
||||
};
|
||||
|
||||
type DetailResp = {
|
||||
contest: Contest;
|
||||
problems: Problem[];
|
||||
registered?: boolean;
|
||||
};
|
||||
|
||||
export default function ContestDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const contestId = useMemo(() => Number(params.id), [params.id]);
|
||||
|
||||
const [detail, setDetail] = useState<DetailResp | null>(null);
|
||||
const [board, setBoard] = useState<LeaderboardRow[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const token = readToken();
|
||||
const d = await apiFetch<DetailResp>(`/api/v1/contests/${contestId}`, {}, token || undefined);
|
||||
const b = await apiFetch<LeaderboardRow[]>(`/api/v1/contests/${contestId}/leaderboard`);
|
||||
setDetail(d);
|
||||
setBoard(b);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(contestId) && contestId > 0) {
|
||||
void load();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contestId]);
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error("请先登录");
|
||||
await apiFetch(`/api/v1/contests/${contestId}/register`, { method: "POST" }, token);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">比赛详情 #{contestId}</h1>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{detail && (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-lg font-medium">{detail.contest.title}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "}
|
||||
{new Date(detail.contest.ends_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
<pre className="mt-3 rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{detail.contest.rule_json}
|
||||
</pre>
|
||||
|
||||
<button
|
||||
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-white"
|
||||
onClick={() => void register()}
|
||||
>
|
||||
{detail.registered ? "已报名(可重复点击刷新)" : "报名比赛"}
|
||||
</button>
|
||||
|
||||
<h3 className="mt-4 text-sm font-medium">比赛题目</h3>
|
||||
<ul className="mt-2 space-y-2 text-sm">
|
||||
{detail.problems.map((p) => (
|
||||
<li key={p.id} className="rounded border p-2">
|
||||
#{p.id} {p.title}(难度 {p.difficulty})
|
||||
<Link className="ml-2 text-blue-600 underline" href={`/problems/${p.id}`}>
|
||||
去提交
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h3 className="text-sm font-medium">排行榜</h3>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-zinc-100 text-left">
|
||||
<tr>
|
||||
<th className="px-2 py-1">#</th>
|
||||
<th className="px-2 py-1">用户</th>
|
||||
<th className="px-2 py-1">Solved</th>
|
||||
<th className="px-2 py-1">Penalty(s)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{board.map((r, idx) => (
|
||||
<tr key={r.user_id} className="border-t">
|
||||
<td className="px-2 py-1">{idx + 1}</td>
|
||||
<td className="px-2 py-1">{r.username}</td>
|
||||
<td className="px-2 py-1">{r.solved}</td>
|
||||
<td className="px-2 py-1">{r.penalty_sec}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Contest = {
|
||||
id: number;
|
||||
title: string;
|
||||
starts_at: number;
|
||||
ends_at: number;
|
||||
rule_json: string;
|
||||
};
|
||||
|
||||
export default function ContestsPage() {
|
||||
const [items, setItems] = useState<Contest[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<Contest[]>("/api/v1/contests");
|
||||
setItems(data);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">模拟竞赛</h1>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/contests/${c.id}`}
|
||||
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
|
||||
>
|
||||
<h2 className="text-lg font-medium">{c.title}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">开始: {new Date(c.starts_at * 1000).toLocaleString()}</p>
|
||||
<p className="text-xs text-zinc-500">结束: {new Date(c.ends_at * 1000).toLocaleString()}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,9 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: Arial, Helvetica, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
260
frontend/src/app/imports/page.tsx
普通文件
260
frontend/src/app/imports/page.tsx
普通文件
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ImportJob = {
|
||||
id: number;
|
||||
status: string;
|
||||
trigger: string;
|
||||
total_count: number;
|
||||
processed_count: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
options_json: string;
|
||||
last_error: string;
|
||||
started_at: number;
|
||||
finished_at: number | null;
|
||||
updated_at: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ImportItem = {
|
||||
id: number;
|
||||
job_id: number;
|
||||
source_path: string;
|
||||
status: string;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
problem_id: number | null;
|
||||
error_text: string;
|
||||
started_at: number | null;
|
||||
finished_at: number | null;
|
||||
updated_at: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type LatestResp = {
|
||||
runner_running: boolean;
|
||||
job: ImportJob | null;
|
||||
};
|
||||
|
||||
type ItemsResp = {
|
||||
items: ImportItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export default function ImportsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [job, setJob] = useState<ImportJob | null>(null);
|
||||
const [items, setItems] = useState<ImportItem[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [clearAllBeforeRun, setClearAllBeforeRun] = useState(true);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (!job || job.total_count <= 0) return 0;
|
||||
return Math.min(100, Math.floor((job.processed_count / job.total_count) * 100));
|
||||
}, [job]);
|
||||
|
||||
const loadLatest = async () => {
|
||||
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest");
|
||||
setJob(latest.job ?? null);
|
||||
setRunning(Boolean(latest.runner_running) || latest.job?.status === "running");
|
||||
return latest.job;
|
||||
};
|
||||
|
||||
const loadItems = async (jobId: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", "1");
|
||||
params.set("page_size", String(pageSize));
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
const data = await apiFetch<ItemsResp>(`/api/v1/import/jobs/${jobId}/items?${params.toString()}`);
|
||||
setItems(data.items ?? []);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const latestJob = await loadLatest();
|
||||
if (latestJob) {
|
||||
await loadItems(latestJob.id);
|
||||
} else {
|
||||
setItems([]);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runImport = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await apiFetch<{ started: boolean }>("/api/v1/import/jobs/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clear_all_problems: clearAllBeforeRun }),
|
||||
});
|
||||
await refresh();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageSize, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, running ? 3000 : 15000);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [running, pageSize, statusFilter]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">题库导入任务(Luogu CSP J/S)</h1>
|
||||
|
||||
<div className="mt-4 rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
onClick={() => void runImport()}
|
||||
disabled={loading || running}
|
||||
>
|
||||
{running ? "导入中..." : "启动导入任务"}
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearAllBeforeRun}
|
||||
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
|
||||
/>
|
||||
启动前清空历史题库
|
||||
</label>
|
||||
<button className="rounded border px-3 py-2 text-sm" onClick={() => void refresh()} disabled={loading}>
|
||||
刷新
|
||||
</button>
|
||||
<span className={`text-sm ${running ? "text-emerald-700" : "text-zinc-600"}`}>
|
||||
{running ? "运行中" : "空闲"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
默认按后端配置以 3 线程执行,抓取洛谷 CSP-J/CSP-S/NOIP 标签题;容器重启后会自动触发导入(可通过环境变量关闭)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<h2 className="text-lg font-medium">最新任务</h2>
|
||||
{!job && <p className="mt-2 text-sm text-zinc-500">暂无任务记录</p>}
|
||||
{job && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<p>
|
||||
任务 #{job.id} · 状态 <b>{job.status}</b> · 触发方式 {job.trigger}
|
||||
</p>
|
||||
<p>
|
||||
总数 {job.total_count},已处理 {job.processed_count},成功 {job.success_count},失败 {job.failed_count}
|
||||
</p>
|
||||
<div className="h-2 w-full rounded bg-zinc-100">
|
||||
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-zinc-600">
|
||||
进度 {progress}% · 开始 {fmtTs(job.started_at)} · 结束 {fmtTs(job.finished_at)}
|
||||
</p>
|
||||
{job.last_error && <p className="text-red-600">最近错误:{job.last_error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-lg font-medium">任务明细</h2>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
>
|
||||
<option value={50}>50 条</option>
|
||||
<option value={100}>100 条</option>
|
||||
<option value={200}>200 条</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead className="bg-zinc-100 text-left">
|
||||
<tr>
|
||||
<th className="px-2 py-2">ID</th>
|
||||
<th className="px-2 py-2">路径</th>
|
||||
<th className="px-2 py-2">状态</th>
|
||||
<th className="px-2 py-2">标题</th>
|
||||
<th className="px-2 py-2">难度</th>
|
||||
<th className="px-2 py-2">题目ID</th>
|
||||
<th className="px-2 py-2">错误</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-t align-top">
|
||||
<td className="px-2 py-2">{item.id}</td>
|
||||
<td className="max-w-[400px] px-2 py-2">
|
||||
<div className="truncate" title={item.source_path}>
|
||||
{item.source_path}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">{item.status}</td>
|
||||
<td className="max-w-[220px] px-2 py-2">
|
||||
<div className="truncate" title={item.title}>
|
||||
{item.title || "-"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">{item.difficulty || "-"}</td>
|
||||
<td className="px-2 py-2">{item.problem_id ?? "-"}</td>
|
||||
<td className="max-w-[320px] px-2 py-2 text-red-600">
|
||||
<div className="truncate" title={item.error_text}>
|
||||
{item.error_text || "-"}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
|
||||
暂无明细
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Article = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
content_md: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type DetailResp = {
|
||||
article: Article;
|
||||
related_problems: { problem_id: number; title: string }[];
|
||||
};
|
||||
|
||||
export default function KbDetailPage() {
|
||||
const params = useParams<{ slug: string }>();
|
||||
const slug = useMemo(() => params.slug, [params.slug]);
|
||||
|
||||
const [data, setData] = useState<DetailResp | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const detail = await apiFetch<DetailResp>(`/api/v1/kb/articles/${slug}`);
|
||||
setData(detail);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (slug) void load();
|
||||
}, [slug]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">知识库文章</h1>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-xl font-medium">{data.article.title}</h2>
|
||||
<pre className="mt-3 whitespace-pre-wrap text-sm">{data.article.content_md}</pre>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h3 className="text-sm font-medium">关联题目</h3>
|
||||
<ul className="mt-2 space-y-2 text-sm">
|
||||
{data.related_problems.map((p) => (
|
||||
<li key={p.problem_id}>
|
||||
<Link className="text-blue-600 underline" href={`/problems/${p.problem_id}`}>
|
||||
#{p.problem_id} {p.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
58
frontend/src/app/kb/page.tsx
普通文件
58
frontend/src/app/kb/page.tsx
普通文件
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Article = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export default function KbListPage() {
|
||||
const [items, setItems] = useState<Article[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<Article[]>("/api/v1/kb/articles");
|
||||
setItems(data);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">学习知识库</h1>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.map((a) => (
|
||||
<Link
|
||||
key={a.slug}
|
||||
href={`/kb/${a.slug}`}
|
||||
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
|
||||
>
|
||||
<h2 className="text-lg font-medium">{a.title}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { AppNav } from "@/components/app-nav";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import "swagger-ui-react/swagger-ui.css";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "CSP 在线学习与竞赛平台",
|
||||
description: "题库、错题本、模拟竞赛、知识库与在线 C++ 运行",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,10 +16,9 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang="zh-CN">
|
||||
<body className="antialiased">
|
||||
<AppNav />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Row = {
|
||||
user_id: number;
|
||||
username: string;
|
||||
rating: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const [items, setItems] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<Row[]>("/api/v1/leaderboard/global?limit=200");
|
||||
setItems(data);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">全站排行榜</h1>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-zinc-100 text-left">
|
||||
<tr>
|
||||
<th className="px-3 py-2">排名</th>
|
||||
<th className="px-3 py-2">用户</th>
|
||||
<th className="px-3 py-2">Rating</th>
|
||||
<th className="px-3 py-2">注册时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row, i) => (
|
||||
<tr key={row.user_id} className="border-t">
|
||||
<td className="px-3 py-2">{i + 1}</td>
|
||||
<td className="px-3 py-2">{row.username}</td>
|
||||
<td className="px-3 py-2">{row.rating}</td>
|
||||
<td className="px-3 py-2">
|
||||
{new Date(row.created_at * 1000).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
54
frontend/src/app/me/page.tsx
普通文件
54
frontend/src/app/me/page.tsx
普通文件
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
|
||||
type Me = {
|
||||
id: number;
|
||||
username: string;
|
||||
rating: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export default function MePage() {
|
||||
const [data, setData] = useState<Me | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error("请先登录");
|
||||
const d = await apiFetch<Me>("/api/v1/me", {}, token);
|
||||
setData(d);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">我的信息</h1>
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 rounded-xl border bg-white p-4 text-sm">
|
||||
<p>ID: {data.id}</p>
|
||||
<p>用户名: {data.username}</p>
|
||||
<p>Rating: {data.rating}</p>
|
||||
<p>创建时间: {new Date(data.created_at * 1000).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8080";
|
||||
|
||||
async function fetchHealth() {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/health`, { cache: "no-store" });
|
||||
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` } as const;
|
||||
return (await r.json()) as { ok: boolean; version?: string };
|
||||
} catch (e: unknown) {
|
||||
return { ok: false, error: String(e) } as const;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const health = await fetchHealth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 text-zinc-900">
|
||||
<main className="mx-auto max-w-3xl px-6 py-12">
|
||||
<h1 className="text-3xl font-semibold">CSP 在线练习平台(MVP)</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">
|
||||
API Base: <span className="font-mono">{API_BASE}</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-6 rounded-xl border bg-white p-5">
|
||||
<h2 className="text-lg font-medium">后端状态</h2>
|
||||
<pre className="mt-3 overflow-auto rounded-lg bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{JSON.stringify(health, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link
|
||||
className="rounded-lg bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800"
|
||||
href="/auth"
|
||||
>
|
||||
注册 / 登录
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-sm text-zinc-500">
|
||||
说明:当前前端仅用于验证注册/登录与基础连通性;题库/提交/比赛/判题会在后续迭代补齐。
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default function Home() {
|
||||
redirect("/problems");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { CodeEditor } from "@/components/code-editor";
|
||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
|
||||
type Problem = {
|
||||
id: number;
|
||||
title: string;
|
||||
statement_md: string;
|
||||
difficulty: number;
|
||||
source: string;
|
||||
statement_url: string;
|
||||
llm_profile_json: string;
|
||||
sample_input: string;
|
||||
sample_output: string;
|
||||
};
|
||||
|
||||
type LlmProfile = {
|
||||
title?: string;
|
||||
difficulty?: number;
|
||||
answer?: string;
|
||||
explanation?: string;
|
||||
knowledge_points?: string[];
|
||||
tags?: string[];
|
||||
statement_summary_md?: string;
|
||||
};
|
||||
|
||||
type Submission = {
|
||||
id: number;
|
||||
status: string;
|
||||
score: number;
|
||||
compile_log: string;
|
||||
runtime_log: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type RunResult = {
|
||||
status: string;
|
||||
time_ms: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
compile_log: string;
|
||||
};
|
||||
|
||||
type DraftResp = {
|
||||
language: string;
|
||||
code: string;
|
||||
stdin: string;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type SolutionItem = {
|
||||
id: number;
|
||||
problem_id: number;
|
||||
variant: number;
|
||||
title: string;
|
||||
idea_md: string;
|
||||
explanation_md: string;
|
||||
code_cpp: string;
|
||||
complexity: string;
|
||||
tags_json: string;
|
||||
source: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type SolutionJob = {
|
||||
id: number;
|
||||
problem_id: number;
|
||||
status: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
created_at: number;
|
||||
started_at: number | null;
|
||||
finished_at: number | null;
|
||||
};
|
||||
|
||||
type SolutionResp = {
|
||||
items: SolutionItem[];
|
||||
latest_job: SolutionJob | null;
|
||||
runner_running: boolean;
|
||||
};
|
||||
|
||||
const starterCode = `#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
int main() {
|
||||
ios::sync_with_stdio(false);
|
||||
cin.tie(nullptr);
|
||||
|
||||
return 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const defaultRunInput = `1 2\n`;
|
||||
|
||||
export default function ProblemDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = useMemo(() => Number(params.id), [params.id]);
|
||||
|
||||
const [problem, setProblem] = useState<Problem | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [code, setCode] = useState(starterCode);
|
||||
const [runInput, setRunInput] = useState(defaultRunInput);
|
||||
const [contestId, setContestId] = useState("");
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [runLoading, setRunLoading] = useState(false);
|
||||
const [draftLoading, setDraftLoading] = useState(false);
|
||||
const [submitResp, setSubmitResp] = useState<Submission | null>(null);
|
||||
const [runResp, setRunResp] = useState<RunResult | null>(null);
|
||||
const [draftMsg, setDraftMsg] = useState("");
|
||||
|
||||
const [showSolutions, setShowSolutions] = useState(false);
|
||||
const [solutionLoading, setSolutionLoading] = useState(false);
|
||||
const [solutionData, setSolutionData] = useState<SolutionResp | null>(null);
|
||||
const [solutionMsg, setSolutionMsg] = useState("");
|
||||
|
||||
const llmProfile = useMemo<LlmProfile | null>(() => {
|
||||
if (!problem?.llm_profile_json) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(problem.llm_profile_json);
|
||||
return typeof parsed === "object" && parsed !== null ? (parsed as LlmProfile) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [problem?.llm_profile_json]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<Problem>(`/api/v1/problems/${id}`);
|
||||
setProblem(data);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDraft = async () => {
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
const token = readToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
const draft = await apiFetch<DraftResp>(`/api/v1/problems/${id}/draft`, undefined, token);
|
||||
if (draft.code) setCode(draft.code);
|
||||
if (draft.stdin) setRunInput(draft.stdin);
|
||||
setDraftMsg("已自动加载草稿");
|
||||
} catch {
|
||||
// ignore empty draft / unauthorized
|
||||
}
|
||||
};
|
||||
void loadDraft();
|
||||
}, [id]);
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitLoading(true);
|
||||
setSubmitResp(null);
|
||||
setError("");
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error("请先登录后再提交评测");
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
language: "cpp",
|
||||
code,
|
||||
};
|
||||
if (contestId) body.contest_id = Number(contestId);
|
||||
|
||||
const resp = await apiFetch<Submission>(
|
||||
`/api/v1/problems/${id}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
token
|
||||
);
|
||||
setSubmitResp(resp);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runCode = async () => {
|
||||
setRunLoading(true);
|
||||
setRunResp(null);
|
||||
setError("");
|
||||
try {
|
||||
const resp = await apiFetch<RunResult>("/api/v1/run/cpp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code, input: runInput }),
|
||||
});
|
||||
setRunResp(resp);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setRunLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
setDraftLoading(true);
|
||||
setDraftMsg("");
|
||||
setError("");
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error("请先登录后再保存草稿");
|
||||
await apiFetch<{ saved: boolean }>(
|
||||
`/api/v1/problems/${id}/draft`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ language: "cpp", code, stdin: runInput }),
|
||||
},
|
||||
token
|
||||
);
|
||||
setDraftMsg("草稿已保存");
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setDraftLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSolutions = async () => {
|
||||
setSolutionLoading(true);
|
||||
setSolutionMsg("");
|
||||
try {
|
||||
const resp = await apiFetch<SolutionResp>(`/api/v1/problems/${id}/solutions`);
|
||||
setSolutionData(resp);
|
||||
} catch (e: unknown) {
|
||||
setSolutionMsg(`加载题解失败:${String(e)}`);
|
||||
} finally {
|
||||
setSolutionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSolutions = async () => {
|
||||
setSolutionLoading(true);
|
||||
setSolutionMsg("");
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error("请先登录后再触发题解生成");
|
||||
await apiFetch<{ started: boolean; job_id: number }>(
|
||||
`/api/v1/problems/${id}/solutions/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ max_solutions: 3 }),
|
||||
},
|
||||
token
|
||||
);
|
||||
setSolutionMsg("题解生成任务已提交,后台异步处理中...");
|
||||
await loadSolutions();
|
||||
} catch (e: unknown) {
|
||||
setSolutionMsg(`提交失败:${String(e)}`);
|
||||
} finally {
|
||||
setSolutionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSolutions) return;
|
||||
void loadSolutions();
|
||||
const timer = setInterval(() => {
|
||||
void loadSolutions();
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showSolutions, id]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1400px] px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">题目详情与评测</h1>
|
||||
|
||||
{loading && <p className="mt-4 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{problem && (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1.1fr,1fr]">
|
||||
<section className="rounded-xl border bg-white p-5">
|
||||
<h2 className="text-xl font-medium">{problem.title}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
难度 {problem.difficulty} · 来源 {problem.source}
|
||||
</p>
|
||||
{problem.statement_url && (
|
||||
<p className="mt-1 text-sm">
|
||||
<a
|
||||
className="text-blue-600 underline"
|
||||
href={problem.statement_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
查看原始题面链接
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border bg-zinc-50 p-4">
|
||||
<MarkdownRenderer markdown={problem.statement_md} />
|
||||
</div>
|
||||
|
||||
{llmProfile?.knowledge_points && llmProfile.knowledge_points.length > 0 && (
|
||||
<>
|
||||
<h3 className="mt-4 text-sm font-medium">知识点考查</h3>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{llmProfile.knowledge_points.map((kp) => (
|
||||
<span key={kp} className="rounded-full bg-zinc-100 px-2 py-1 text-xs">
|
||||
{kp}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="mt-4 text-sm font-medium">样例输入</h3>
|
||||
<pre className="rounded bg-zinc-900 p-3 text-xs text-zinc-100">{problem.sample_input}</pre>
|
||||
|
||||
<h3 className="mt-3 text-sm font-medium">样例输出</h3>
|
||||
<pre className="rounded bg-zinc-900 p-3 text-xs text-zinc-100">{problem.sample_output}</pre>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-white p-5">
|
||||
<label className="text-sm font-medium">contest_id(可选)</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
placeholder="例如 1"
|
||||
value={contestId}
|
||||
onChange={(e) => setContestId(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
onClick={() => void submit()}
|
||||
disabled={submitLoading}
|
||||
>
|
||||
{submitLoading ? "提交中..." : "提交评测"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-4 py-2 text-sm disabled:opacity-50"
|
||||
onClick={() => void saveDraft()}
|
||||
disabled={draftLoading}
|
||||
>
|
||||
{draftLoading ? "保存中..." : "保存草稿"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-4 py-2 text-sm disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setShowSolutions((v) => !v);
|
||||
}}
|
||||
disabled={solutionLoading}
|
||||
>
|
||||
答案展示
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{draftMsg && <p className="mt-2 text-xs text-emerald-700">{draftMsg}</p>}
|
||||
|
||||
<label className="mt-4 block text-sm font-medium">C++ 代码(高亮 + 自动提示)</label>
|
||||
<div className="mt-1 overflow-hidden rounded border">
|
||||
<CodeEditor value={code} onChange={setCode} height="420px" />
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block text-sm font-medium">试运行输入</label>
|
||||
<textarea
|
||||
className="mt-1 h-24 w-full rounded border p-2 font-mono text-xs"
|
||||
value={runInput}
|
||||
onChange={(e) => setRunInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="mt-2 rounded border px-4 py-2 text-sm disabled:opacity-50"
|
||||
onClick={() => void runCode()}
|
||||
disabled={runLoading}
|
||||
>
|
||||
{runLoading ? "试运行中..." : "试运行查看结果"}
|
||||
</button>
|
||||
|
||||
{runResp && (
|
||||
<div className="mt-3 space-y-2 rounded border p-3 text-xs">
|
||||
<p>
|
||||
运行状态:<b>{runResp.status}</b> · 耗时 {runResp.time_ms}ms
|
||||
</p>
|
||||
{runResp.compile_log && (
|
||||
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
|
||||
{runResp.compile_log}
|
||||
</pre>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">stdout</p>
|
||||
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
|
||||
{runResp.stdout || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">stderr</p>
|
||||
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-zinc-100">
|
||||
{runResp.stderr || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitResp && (
|
||||
<div className="mt-4 space-y-2 rounded border p-3 text-sm">
|
||||
<p>
|
||||
结果:<b>{submitResp.status}</b>,分数 {submitResp.score}
|
||||
</p>
|
||||
<p>
|
||||
提交 ID:
|
||||
<Link className="ml-1 text-blue-600 underline" href={`/submissions/${submitResp.id}`}>
|
||||
{submitResp.id}
|
||||
</Link>
|
||||
</p>
|
||||
{submitResp.compile_log && (
|
||||
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
|
||||
{submitResp.compile_log}
|
||||
</pre>
|
||||
)}
|
||||
{submitResp.runtime_log && (
|
||||
<pre className="overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
|
||||
{submitResp.runtime_log}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSolutions && (
|
||||
<div className="mt-5 rounded-lg border bg-zinc-50 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">官方/LLM 题解</h3>
|
||||
<button
|
||||
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
|
||||
onClick={() => void triggerSolutions()}
|
||||
disabled={solutionLoading}
|
||||
>
|
||||
异步生成多解
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
|
||||
onClick={() => void loadSolutions()}
|
||||
disabled={solutionLoading}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{solutionMsg && <p className="mt-2 text-xs text-zinc-600">{solutionMsg}</p>}
|
||||
|
||||
{solutionData?.latest_job && (
|
||||
<p className="mt-2 text-xs text-zinc-600">
|
||||
任务 #{solutionData.latest_job.id} · {solutionData.latest_job.status} ·
|
||||
进度 {solutionData.latest_job.progress}%
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{(solutionData?.items ?? []).map((item) => (
|
||||
<article key={item.id} className="rounded border bg-white p-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
解法 {item.variant}:{item.title || "未命名解法"}
|
||||
</h4>
|
||||
{item.complexity && (
|
||||
<p className="mt-1 text-xs text-zinc-600">复杂度:{item.complexity}</p>
|
||||
)}
|
||||
{item.idea_md && (
|
||||
<div className="mt-2 rounded bg-zinc-50 p-2">
|
||||
<MarkdownRenderer markdown={item.idea_md} />
|
||||
</div>
|
||||
)}
|
||||
{item.code_cpp && (
|
||||
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-2 text-xs text-zinc-100">
|
||||
{item.code_cpp}
|
||||
</pre>
|
||||
)}
|
||||
{item.explanation_md && (
|
||||
<div className="mt-2 rounded bg-zinc-50 p-2">
|
||||
<MarkdownRenderer markdown={item.explanation_md} />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{!solutionLoading && (solutionData?.items.length ?? 0) === 0 && (
|
||||
<p className="text-xs text-zinc-500">暂无题解,点击“异步生成多解”可触发后台生成。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
415
frontend/src/app/problems/page.tsx
普通文件
415
frontend/src/app/problems/page.tsx
普通文件
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Problem = {
|
||||
id: number;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
source: string;
|
||||
llm_profile_json: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ProblemListResp = {
|
||||
items: Problem[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
type ProblemProfile = {
|
||||
pid?: string;
|
||||
tags?: string[];
|
||||
knowledge_points?: string[];
|
||||
stats?: {
|
||||
total_submit?: number;
|
||||
total_accepted?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type Preset = {
|
||||
key: string;
|
||||
label: string;
|
||||
sourcePrefix?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
const PRESETS: Preset[] = [
|
||||
{
|
||||
key: "csp-beginner-default",
|
||||
label: "CSP J/S 入门默认",
|
||||
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
|
||||
},
|
||||
{
|
||||
key: "csp-j",
|
||||
label: "仅 CSP-J / 普及",
|
||||
tags: ["csp-j", "noip-junior"],
|
||||
},
|
||||
{
|
||||
key: "csp-s",
|
||||
label: "仅 CSP-S / 提高",
|
||||
tags: ["csp-s", "noip-senior"],
|
||||
},
|
||||
{
|
||||
key: "noip-junior",
|
||||
label: "仅 NOIP 入门",
|
||||
tags: ["noip-junior"],
|
||||
},
|
||||
{
|
||||
key: "luogu-all",
|
||||
label: "洛谷导入全部",
|
||||
sourcePrefix: "luogu:",
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
key: "all",
|
||||
label: "全站全部来源",
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const QUICK_CARDS = [
|
||||
{
|
||||
presetKey: "csp-j",
|
||||
title: "CSP-J 真题",
|
||||
desc: "普及组入门训练",
|
||||
},
|
||||
{
|
||||
presetKey: "csp-s",
|
||||
title: "CSP-S 真题",
|
||||
desc: "提高组进阶训练",
|
||||
},
|
||||
{
|
||||
presetKey: "noip-junior",
|
||||
title: "NOIP 入门",
|
||||
desc: "基础算法与思维",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{ value: "0", label: "全部难度" },
|
||||
{ value: "1", label: "1" },
|
||||
{ value: "2", label: "2" },
|
||||
{ value: "3", label: "3" },
|
||||
{ value: "4", label: "4" },
|
||||
{ value: "5", label: "5" },
|
||||
{ value: "6", label: "6" },
|
||||
{ value: "7", label: "7" },
|
||||
{ value: "8", label: "8" },
|
||||
{ value: "9", label: "9" },
|
||||
{ value: "10", label: "10" },
|
||||
] as const;
|
||||
|
||||
function parseProfile(raw: string): ProblemProfile | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === "object" && parsed !== null ? (parsed as ProblemProfile) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function difficultyClass(diff: number): string {
|
||||
if (diff <= 2) return "text-emerald-600";
|
||||
if (diff <= 4) return "text-blue-600";
|
||||
if (diff <= 6) return "text-orange-600";
|
||||
return "text-rose-600";
|
||||
}
|
||||
|
||||
function resolvePid(problem: Problem, profile: ProblemProfile | null): string {
|
||||
if (problem.source.startsWith("luogu:")) {
|
||||
return problem.source.slice("luogu:".length);
|
||||
}
|
||||
if (profile?.pid) return profile.pid;
|
||||
const head = problem.title.split(" ")[0] ?? "";
|
||||
return /^[A-Za-z]\d+$/.test(head) ? head : String(problem.id);
|
||||
}
|
||||
|
||||
function resolvePassRate(profile: ProblemProfile | null): string {
|
||||
const accepted = profile?.stats?.total_accepted;
|
||||
const submitted = profile?.stats?.total_submit;
|
||||
if (!submitted || submitted <= 0 || accepted === undefined) return "-";
|
||||
const rate = ((accepted / submitted) * 100).toFixed(1);
|
||||
return `${accepted}/${submitted} (${rate}%)`;
|
||||
}
|
||||
|
||||
function resolveTags(profile: ProblemProfile | null): string[] {
|
||||
const tags = (profile?.tags ?? []).slice(0, 3);
|
||||
if (tags.length > 0) return tags;
|
||||
return (profile?.knowledge_points ?? []).slice(0, 3);
|
||||
}
|
||||
|
||||
export default function ProblemsPage() {
|
||||
const [presetKey, setPresetKey] = useState(PRESETS[0].key);
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [difficulty, setDifficulty] = useState("0");
|
||||
const [orderBy, setOrderBy] = useState("id");
|
||||
const [order, setOrder] = useState("asc");
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [items, setItems] = useState<Problem[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const preset = useMemo(
|
||||
() => PRESETS.find((item) => item.key === presetKey) ?? PRESETS[0],
|
||||
[presetKey]
|
||||
);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (totalCount <= 0) return 1;
|
||||
return Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
}, [totalCount, pageSize]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", String(pageSize));
|
||||
params.set("order_by", orderBy);
|
||||
params.set("order", order);
|
||||
if (keyword) params.set("q", keyword);
|
||||
if (difficulty !== "0") params.set("difficulty", difficulty);
|
||||
if (preset.sourcePrefix) params.set("source_prefix", preset.sourcePrefix);
|
||||
if (preset.tags && preset.tags.length > 0) params.set("tags", preset.tags.join(","));
|
||||
|
||||
const data = await apiFetch<ProblemListResp>(`/api/v1/problems?${params.toString()}`);
|
||||
setItems(data.items ?? []);
|
||||
setTotalCount(data.total_count ?? 0);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [difficulty, keyword, order, orderBy, page, pageSize, preset.sourcePrefix, preset.tags]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
items.map((problem) => {
|
||||
const profile = parseProfile(problem.llm_profile_json);
|
||||
return { problem, profile };
|
||||
}),
|
||||
[items]
|
||||
);
|
||||
|
||||
const applySearch = () => {
|
||||
setPage(1);
|
||||
setKeyword(keywordInput.trim());
|
||||
};
|
||||
|
||||
const selectPreset = (key: string) => {
|
||||
setPresetKey(key);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">题库(CSP J/S 入门)</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
参考洛谷题库列表交互,默认聚焦 CSP-J / CSP-S / NOIP 入门训练。
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600">共 {totalCount} 题</p>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{QUICK_CARDS.map((card) => {
|
||||
const active = presetKey === card.presetKey;
|
||||
return (
|
||||
<button
|
||||
key={card.presetKey}
|
||||
type="button"
|
||||
className={`rounded-xl border px-4 py-3 text-left transition ${
|
||||
active
|
||||
? "border-zinc-900 bg-zinc-900 text-white"
|
||||
: "bg-white text-zinc-900 hover:border-zinc-400"
|
||||
}`}
|
||||
onClick={() => selectPreset(card.presetKey)}
|
||||
>
|
||||
<p className="text-base font-semibold">{card.title}</p>
|
||||
<p className={`mt-1 text-xs ${active ? "text-zinc-200" : "text-zinc-500"}`}>
|
||||
{card.desc}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-2 lg:grid-cols-6">
|
||||
<select
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={presetKey}
|
||||
onChange={(e) => {
|
||||
selectPreset(e.target.value);
|
||||
}}
|
||||
>
|
||||
{PRESETS.map((item) => (
|
||||
<option key={item.key} value={item.key}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="rounded border px-3 py-2 text-sm lg:col-span-2"
|
||||
placeholder="搜索题号/标题/题面关键词"
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={difficulty}
|
||||
onChange={(e) => {
|
||||
setDifficulty(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
难度 {item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={`${orderBy}:${order}`}
|
||||
onChange={(e) => {
|
||||
const [ob, od] = e.target.value.split(":");
|
||||
setOrderBy(ob || "id");
|
||||
setOrder(od || "asc");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="id:asc">题号升序</option>
|
||||
<option value="id:desc">题号降序</option>
|
||||
<option value="difficulty:asc">难度升序</option>
|
||||
<option value="difficulty:desc">难度降序</option>
|
||||
<option value="created_at:desc">最新导入</option>
|
||||
<option value="title:asc">标题 A-Z</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
onClick={applySearch}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "加载中..." : "搜索"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<section className="mt-4 overflow-x-auto rounded-xl border bg-white">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-zinc-100 text-left text-zinc-700">
|
||||
<tr>
|
||||
<th className="px-3 py-2">题号</th>
|
||||
<th className="px-3 py-2">标题</th>
|
||||
<th className="px-3 py-2">通过/提交</th>
|
||||
<th className="px-3 py-2">难度</th>
|
||||
<th className="px-3 py-2">标签</th>
|
||||
<th className="px-3 py-2">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ problem, profile }) => {
|
||||
const pid = resolvePid(problem, profile);
|
||||
const tags = resolveTags(profile);
|
||||
return (
|
||||
<tr key={problem.id} className="border-t hover:bg-zinc-50">
|
||||
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Link className="hover:underline" href={`/problems/${problem.id}`}>
|
||||
{problem.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td>
|
||||
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}>
|
||||
{problem.difficulty}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.length === 0 && <span className="text-zinc-400">-</span>}
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-zinc-500">{problem.source || "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
|
||||
当前筛选下暂无题目,请切换题单预设或先执行导入脚本。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={loading || page <= 1}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={loading || page >= totalPages}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span>每页</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1"
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
110
frontend/src/app/run/page.tsx
普通文件
110
frontend/src/app/run/page.tsx
普通文件
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type RunResult = {
|
||||
status: string;
|
||||
time_ms: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
compile_log: string;
|
||||
};
|
||||
|
||||
const starterCode = `#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
int main() {
|
||||
string s;
|
||||
getline(cin, s);
|
||||
cout << s << "\\n";
|
||||
return 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function RunPage() {
|
||||
const [code, setCode] = useState(starterCode);
|
||||
const [input, setInput] = useState("hello csp");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState<RunResult | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await apiFetch<RunResult>("/api/v1/run/cpp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code, input }),
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">在线 C++ 编写 / 编译 / 运行</h1>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-sm font-medium">代码</h2>
|
||||
<textarea
|
||||
className="mt-2 h-[420px] w-full rounded border p-3 font-mono text-sm"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-sm font-medium">标准输入</h2>
|
||||
<textarea
|
||||
className="mt-2 h-32 w-full rounded border p-3 font-mono text-sm"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
onClick={() => void run()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "运行中..." : "运行"}
|
||||
</button>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{result && (
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<p>
|
||||
状态: <b>{result.status}</b> · 耗时: {result.time_ms}ms
|
||||
</p>
|
||||
<div>
|
||||
<h3 className="font-medium">stdout</h3>
|
||||
<pre className="mt-1 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{result.stdout || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">stderr</h3>
|
||||
<pre className="mt-1 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{result.stderr || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">compile_log</h3>
|
||||
<pre className="mt-1 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{result.compile_log || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Submission = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
problem_id: number;
|
||||
contest_id: number | null;
|
||||
language: string;
|
||||
code: string;
|
||||
status: string;
|
||||
score: number;
|
||||
time_ms: number;
|
||||
memory_kb: number;
|
||||
compile_log: string;
|
||||
runtime_log: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export default function SubmissionDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = useMemo(() => Number(params.id), [params.id]);
|
||||
|
||||
const [data, setData] = useState<Submission | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const d = await apiFetch<Submission>(`/api/v1/submissions/${id}`);
|
||||
setData(d);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (Number.isFinite(id) && id > 0) void load();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">提交详情 #{id}</h1>
|
||||
{loading && <p className="mt-4 text-sm text-zinc-500">加载中...</p>}
|
||||
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-xl border bg-white p-4 text-sm">
|
||||
<p>用户: {data.user_id}</p>
|
||||
<p>题目: {data.problem_id}</p>
|
||||
<p>比赛: {data.contest_id ?? "-"}</p>
|
||||
<p>语言: {data.language}</p>
|
||||
<p>状态: {data.status}</p>
|
||||
<p>分数: {data.score}</p>
|
||||
<p>时间: {data.time_ms} ms</p>
|
||||
<p>内存: {data.memory_kb} KB</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-sm font-medium">代码</h2>
|
||||
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{data.code}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-sm font-medium">编译日志</h2>
|
||||
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{data.compile_log || "(empty)"}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-sm font-medium">运行日志</h2>
|
||||
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{data.runtime_log || "(empty)"}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type Submission = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
problem_id: number;
|
||||
contest_id: number | null;
|
||||
status: string;
|
||||
score: number;
|
||||
time_ms: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ListResp = { items: Submission[]; page: number; page_size: number };
|
||||
|
||||
export default function SubmissionsPage() {
|
||||
const [userId, setUserId] = useState("");
|
||||
const [problemId, setProblemId] = useState("");
|
||||
const [contestId, setContestId] = useState("");
|
||||
const [items, setItems] = useState<Submission[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set("user_id", userId);
|
||||
if (problemId) params.set("problem_id", problemId);
|
||||
if (contestId) params.set("contest_id", contestId);
|
||||
const data = await apiFetch<ListResp>(`/api/v1/submissions?${params.toString()}`);
|
||||
setItems(data.items);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">提交记录</h1>
|
||||
|
||||
<div className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-4">
|
||||
<input
|
||||
className="rounded border px-3 py-2"
|
||||
placeholder="user_id"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded border px-3 py-2"
|
||||
placeholder="problem_id"
|
||||
value={problemId}
|
||||
onChange={(e) => setProblemId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded border px-3 py-2"
|
||||
placeholder="contest_id"
|
||||
value={contestId}
|
||||
onChange={(e) => setContestId(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "加载中..." : "筛选"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-zinc-100 text-left">
|
||||
<tr>
|
||||
<th className="px-3 py-2">ID</th>
|
||||
<th className="px-3 py-2">用户</th>
|
||||
<th className="px-3 py-2">题目</th>
|
||||
<th className="px-3 py-2">状态</th>
|
||||
<th className="px-3 py-2">分数</th>
|
||||
<th className="px-3 py-2">耗时(ms)</th>
|
||||
<th className="px-3 py-2">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="border-t">
|
||||
<td className="px-3 py-2">{s.id}</td>
|
||||
<td className="px-3 py-2">{s.user_id}</td>
|
||||
<td className="px-3 py-2">{s.problem_id}</td>
|
||||
<td className="px-3 py-2">{s.status}</td>
|
||||
<td className="px-3 py-2">{s.score}</td>
|
||||
<td className="px-3 py-2">{s.time_ms}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
|
||||
查看
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
126
frontend/src/app/wrong-book/page.tsx
普通文件
126
frontend/src/app/wrong-book/page.tsx
普通文件
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
|
||||
type WrongBookItem = {
|
||||
user_id: number;
|
||||
problem_id: number;
|
||||
problem_title: string;
|
||||
last_submission_id: number | null;
|
||||
note: string;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export default function WrongBookPage() {
|
||||
const [token, setToken] = useState("");
|
||||
const [items, setItems] = useState<WrongBookItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setToken(readToken());
|
||||
}, []);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
if (!token) throw new Error("请先登录");
|
||||
const data = await apiFetch<WrongBookItem[]>("/api/v1/me/wrong-book", {}, token);
|
||||
setItems(data);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const updateNote = async (problemId: number, note: string) => {
|
||||
try {
|
||||
await apiFetch(`/api/v1/me/wrong-book/${problemId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note }),
|
||||
}, token);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const removeItem = async (problemId: number) => {
|
||||
try {
|
||||
await apiFetch(`/api/v1/me/wrong-book/${problemId}`, { method: "DELETE" }, token);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">错题本</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600">未通过提交会自动进入错题本。</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.problem_id} className="rounded-xl border bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium">
|
||||
#{item.problem_id} {item.problem_title}
|
||||
</p>
|
||||
<button
|
||||
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
||||
onClick={() => void removeItem(item.problem_id)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
最近提交: {item.last_submission_id ?? "-"}
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
className="mt-2 h-24 w-full rounded border p-2 text-sm"
|
||||
value={item.note}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setItems((prev) =>
|
||||
prev.map((x) =>
|
||||
x.problem_id === item.problem_id ? { ...x, note: next } : x
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="mt-2 rounded border px-3 py-1 text-sm hover:bg-zinc-100"
|
||||
onClick={() => void updateNote(item.problem_id, item.note)}
|
||||
>
|
||||
保存备注
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { clearToken, readToken } from "@/lib/auth";
|
||||
|
||||
const links = [
|
||||
["首页", "/"],
|
||||
["登录", "/auth"],
|
||||
["题库", "/problems"],
|
||||
["提交", "/submissions"],
|
||||
["错题本", "/wrong-book"],
|
||||
["比赛", "/contests"],
|
||||
["知识库", "/kb"],
|
||||
["导入任务", "/imports"],
|
||||
["在线运行", "/run"],
|
||||
["我的", "/me"],
|
||||
["排行榜", "/leaderboard"],
|
||||
["API文档", "/api-docs"],
|
||||
] as const;
|
||||
|
||||
export function AppNav() {
|
||||
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setHasToken(Boolean(readToken()));
|
||||
window.addEventListener("storage", refresh);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
window.removeEventListener("storage", refresh);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-2 px-4 py-3">
|
||||
{links.map(([label, href]) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
|
||||
{hasToken ? "已登录" : "未登录"}
|
||||
</span>
|
||||
{hasToken && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearToken();
|
||||
setHasToken(false);
|
||||
}}
|
||||
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
height?: string;
|
||||
};
|
||||
|
||||
export function CodeEditor({ value, onChange, height = "420px" }: Props) {
|
||||
return (
|
||||
<MonacoEditor
|
||||
height={height}
|
||||
language="cpp"
|
||||
value={value}
|
||||
options={{
|
||||
fontSize: 14,
|
||||
minimap: { enabled: false },
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
wordWrap: "on",
|
||||
suggestOnTriggerCharacters: true,
|
||||
quickSuggestions: {
|
||||
other: true,
|
||||
comments: false,
|
||||
strings: false,
|
||||
},
|
||||
}}
|
||||
onMount={(editor, monaco) => {
|
||||
monaco.languages.registerCompletionItemProvider("cpp", {
|
||||
provideCompletionItems: () => ({
|
||||
suggestions: [
|
||||
{
|
||||
label: "ios",
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText:
|
||||
"ios::sync_with_stdio(false);\\ncin.tie(nullptr);\\n",
|
||||
documentation: "Fast IO",
|
||||
},
|
||||
{
|
||||
label: "fori",
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: "for (int i = 0; i < ${1:n}; ++i) {\\n ${2}\\n}",
|
||||
insertTextRules:
|
||||
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: "for loop",
|
||||
},
|
||||
{
|
||||
label: "vector",
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: "vector<int> ${1:arr}(${2:n});",
|
||||
insertTextRules:
|
||||
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
// handled by page-level save button; reserve shortcut for UX consistency.
|
||||
});
|
||||
}}
|
||||
onChange={(next) => onChange(next ?? "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
|
||||
type Props = {
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
function normalizeImageSrc(src: string): string {
|
||||
if (!src) return src;
|
||||
if (src.startsWith("http://") || src.startsWith("https://")) {
|
||||
return `/api/image-cache?url=${encodeURIComponent(src)}`;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ markdown }: Props) {
|
||||
return (
|
||||
<article className="space-y-3 text-sm leading-7 text-zinc-800">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex, rehypeHighlight]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className="mt-6 text-2xl font-semibold">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mt-5 text-xl font-semibold">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mt-4 text-lg font-semibold">{children}</h3>,
|
||||
p: ({ children }) => <p className="whitespace-pre-wrap">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc space-y-1 pl-5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal space-y-1 pl-5">{children}</ol>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-zinc-300 pl-3 text-zinc-600">{children}</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th className="border bg-zinc-100 px-2 py-1 text-left">{children}</th>,
|
||||
td: ({ children }) => <td className="border px-2 py-1 align-top">{children}</td>,
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isInline = !className;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="rounded bg-zinc-100 px-1 py-0.5 text-xs text-zinc-800" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={`${className ?? ""} block overflow-x-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) => {
|
||||
const safeSrc = typeof src === "string" ? src : "";
|
||||
const safeAlt = typeof alt === "string" ? alt : "";
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={normalizeImageSrc(safeSrc)}
|
||||
alt={safeAlt}
|
||||
className="my-3 max-h-[480px] rounded border object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
},
|
||||
a: ({ href, children }) => {
|
||||
const safeHref = typeof href === "string" ? href : "#";
|
||||
return (
|
||||
<a href={safeHref} target="_blank" rel="noreferrer" className="text-blue-600 underline">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
54
frontend/src/lib/api.ts
普通文件
54
frontend/src/lib/api.ts
普通文件
@@ -0,0 +1,54 @@
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE ??
|
||||
(process.env.NODE_ENV === "development" ? "http://localhost:8080" : "/admin139");
|
||||
|
||||
type ApiEnvelope<T> =
|
||||
| { ok: true; data?: T; [k: string]: unknown }
|
||||
| { ok: false; error?: string; [k: string]: unknown };
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
if (init?.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const resp = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const text = await resp.text();
|
||||
let payload: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const msg =
|
||||
typeof payload === "object" && payload !== null && "error" in payload
|
||||
? String((payload as { error?: unknown }).error ?? `HTTP ${resp.status}`)
|
||||
: `HTTP ${resp.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
if (typeof payload === "object" && payload !== null && "ok" in payload) {
|
||||
const env = payload as ApiEnvelope<T>;
|
||||
if (!env.ok) {
|
||||
throw new Error(env.error ?? "request failed");
|
||||
}
|
||||
if ("data" in env) return (env.data as T) ?? ({} as T);
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
16
frontend/src/lib/auth.ts
普通文件
16
frontend/src/lib/auth.ts
普通文件
@@ -0,0 +1,16 @@
|
||||
const TOKEN_KEY = "csp_token";
|
||||
|
||||
export function readToken(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return window.localStorage.getItem(TOKEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function saveToken(token: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
6
frontend/src/types/swagger-ui-react.d.ts
vendored
普通文件
6
frontend/src/types/swagger-ui-react.d.ts
vendored
普通文件
@@ -0,0 +1,6 @@
|
||||
declare module "swagger-ui-react" {
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
const SwaggerUI: ComponentType<Record<string, unknown>>;
|
||||
export default SwaggerUI;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户