first commit
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>题库管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5213
frontend/package-lock.json
generated
Normal file
5213
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "problem-bank-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.2.7",
|
||||
"antd": "^5.27.0",
|
||||
"axios": "^1.11.0",
|
||||
"katex": "^0.16.11",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.24",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
67
frontend/src/App.tsx
Normal file
67
frontend/src/App.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Layout, Menu, message } from "antd";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import LoginModal from "./components/LoginModal";
|
||||
import Categories from "./pages/Categories";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ExportPage from "./pages/Export";
|
||||
import ImportPage from "./pages/Import";
|
||||
import PracticePage from "./pages/Practice";
|
||||
import QuestionEdit from "./pages/QuestionEdit";
|
||||
import Questions from "./pages/Questions";
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
const menuItems = [
|
||||
{ key: "/dashboard", label: "仪表盘" },
|
||||
{ key: "/questions", label: "题目管理" },
|
||||
{ key: "/import", label: "导入中心" },
|
||||
{ key: "/export", label: "导出" },
|
||||
{ key: "/categories", label: "分类管理" },
|
||||
{ key: "/practice", label: "练习模式" }
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [loggedIn, setLoggedIn] = useState(Boolean(localStorage.getItem("pb_token")));
|
||||
|
||||
const selectedKey = useMemo(() => {
|
||||
const hit = menuItems.find((m) => location.pathname.startsWith(m.key));
|
||||
return hit ? [hit.key] : ["/dashboard"];
|
||||
}, [location.pathname]);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("pb_token");
|
||||
setLoggedIn(false);
|
||||
message.info("已退出登录");
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<LoginModal open={!loggedIn} onSuccess={() => setLoggedIn(true)} />
|
||||
<Sider theme="light">
|
||||
<div className="logo">题库系统</div>
|
||||
<Menu mode="inline" selectedKeys={selectedKey} items={menuItems} onClick={(e) => navigate(e.key)} />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="header">
|
||||
<span>网页版题库管理系统</span>
|
||||
<a onClick={logout}>退出</a>
|
||||
</Header>
|
||||
<Content className="content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/questions" element={<Questions />} />
|
||||
<Route path="/questions/:id" element={<QuestionEdit />} />
|
||||
<Route path="/import" element={<ImportPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/categories" element={<Categories />} />
|
||||
<Route path="/practice" element={<PracticePage />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
15
frontend/src/api/index.ts
Normal file
15
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "http://127.0.0.1:8000/api"
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("pb_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
||||
20
frontend/src/components/LatexRenderer.tsx
Normal file
20
frontend/src/components/LatexRenderer.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import katex from "katex";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function renderLatexText(text: string): string {
|
||||
if (!text) return "";
|
||||
return text.replace(/\$(.+?)\$/g, (_, expr) => {
|
||||
try {
|
||||
return katex.renderToString(expr, { throwOnError: false });
|
||||
} catch {
|
||||
return `$${expr}$`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function LatexRenderer({ text }: Props) {
|
||||
return <span dangerouslySetInnerHTML={{ __html: renderLatexText(text) }} />;
|
||||
}
|
||||
38
frontend/src/components/LoginModal.tsx
Normal file
38
frontend/src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Button, Form, Input, Modal, message } from "antd";
|
||||
import api from "../api";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function LoginModal({ open, onSuccess }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
const { data } = await api.post("/auth/login", values);
|
||||
localStorage.setItem("pb_token", data.access_token);
|
||||
message.success("登录成功");
|
||||
onSuccess();
|
||||
} catch {
|
||||
message.error("登录失败,请检查用户名或密码");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} title="管理员登录" footer={null} closable={false} maskClosable={false}>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item label="用户名" name="username" rules={[{ required: true }]}>
|
||||
<Input placeholder="admin" />
|
||||
</Form.Item>
|
||||
<Form.Item label="密码" name="password" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/QuestionCard.tsx
Normal file
24
frontend/src/components/QuestionCard.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Card, Tag } from "antd";
|
||||
import LatexRenderer from "./LatexRenderer";
|
||||
import { Question } from "../types";
|
||||
|
||||
interface Props {
|
||||
question: Question;
|
||||
}
|
||||
|
||||
export default function QuestionCard({ question }: Props) {
|
||||
return (
|
||||
<Card
|
||||
title={`${question.id}. ${question.question_type || "未分类题型"}`}
|
||||
extra={<Tag>{question.difficulty || "未知难度"}</Tag>}
|
||||
size="small"
|
||||
>
|
||||
<div><LatexRenderer text={question.stem} /></div>
|
||||
{question.option_a && <div>A. <LatexRenderer text={question.option_a} /></div>}
|
||||
{question.option_b && <div>B. <LatexRenderer text={question.option_b} /></div>}
|
||||
{question.option_c && <div>C. <LatexRenderer text={question.option_c} /></div>}
|
||||
{question.option_d && <div>D. <LatexRenderer text={question.option_d} /></div>}
|
||||
<div>答案:{question.answer}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "antd/dist/reset.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
57
frontend/src/pages/Categories.tsx
Normal file
57
frontend/src/pages/Categories.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Space, Tree, message } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import api from "../api";
|
||||
import { CategoryNode } from "../types";
|
||||
|
||||
function mapTree(nodes: CategoryNode[]): any[] {
|
||||
return nodes.map((n) => ({
|
||||
key: n.id,
|
||||
title: `${n.name} (${n.count ?? 0})`,
|
||||
children: n.children ? mapTree(n.children) : []
|
||||
}));
|
||||
}
|
||||
|
||||
export default function Categories() {
|
||||
const [items, setItems] = useState<CategoryNode[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = () => api.get("/categories").then((res) => setItems(res.data.items || []));
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const treeData = useMemo(() => mapTree(items), [items]);
|
||||
|
||||
const onCreate = async () => {
|
||||
await api.post("/categories", form.getFieldsValue());
|
||||
message.success("分类创建成功");
|
||||
setOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="分类管理"
|
||||
extra={<Button type="primary" onClick={() => setOpen(true)}>新增分类</Button>}
|
||||
>
|
||||
<Tree treeData={treeData} defaultExpandAll />
|
||||
<Modal open={open} title="新增分类" onCancel={() => setOpen(false)} onOk={onCreate}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="名称" name="name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space style={{ width: "100%" }}>
|
||||
<Form.Item label="父节点ID" name="parent_id">
|
||||
<InputNumber style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="层级" name="level" initialValue={1}>
|
||||
<InputNumber min={1} max={3} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
67
frontend/src/pages/Dashboard.tsx
Normal file
67
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Col, List, Row, Statistic } from "antd";
|
||||
import { Column } from "@ant-design/charts";
|
||||
import api from "../api";
|
||||
|
||||
interface StatsData {
|
||||
total: number;
|
||||
by_type: { name: string; value: number }[];
|
||||
by_difficulty: { name: string; value: number }[];
|
||||
by_chapter: { name: string; value: number }[];
|
||||
latest_imports: { id: number; filename: string; method: string; question_count: number }[];
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<StatsData>({
|
||||
total: 0,
|
||||
by_type: [],
|
||||
by_difficulty: [],
|
||||
by_chapter: [],
|
||||
latest_imports: []
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/stats").then((res) => setStats(res.data));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="题目总数" value={stats.total} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="题型数量" value={stats.by_type.length} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="章节数量" value={stats.by_chapter.length} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="最近导入次数" value={stats.latest_imports.length} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card title="题型分布">
|
||||
<Column data={stats.by_type} xField="name" yField="value" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="难度分布">
|
||||
<Column data={stats.by_difficulty} xField="name" yField="value" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Card title="最近导入记录" style={{ marginTop: 16 }}>
|
||||
<List
|
||||
dataSource={stats.latest_imports}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
{item.filename} | {item.method} | {item.question_count} 题
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/pages/Export.tsx
Normal file
80
frontend/src/pages/Export.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Button, Card, Form, Radio, Select, Space, message } from "antd";
|
||||
import api from "../api";
|
||||
|
||||
function downloadText(filename: string, content: string, mime: string) {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function hexToArrayBuffer(hex: string): ArrayBuffer {
|
||||
const length = hex.length / 2;
|
||||
const arr = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
arr[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
}
|
||||
return arr.buffer;
|
||||
}
|
||||
|
||||
export default function ExportPage() {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onExport = async () => {
|
||||
const values = form.getFieldsValue();
|
||||
const { data } = await api.get("/export", { params: values });
|
||||
if (data.format === "json") {
|
||||
downloadText("questions.json", data.content, "application/json");
|
||||
} else if (data.format === "csv") {
|
||||
downloadText("questions.csv", data.content, "text/csv;charset=utf-8");
|
||||
} else {
|
||||
const blob = new Blob([hexToArrayBuffer(data.content_base64)], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "questions.xlsx";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
message.success("导出完成");
|
||||
};
|
||||
|
||||
const downloadTemplate = async () => {
|
||||
const { data } = await api.get("/import/template");
|
||||
const blob = new Blob([hexToArrayBuffer(data.content_base64)], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = data.filename || "template.xlsx";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="导出题库">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="format" label="导出格式" initialValue="json">
|
||||
<Radio.Group options={[
|
||||
{ label: "JSON", value: "json" },
|
||||
{ label: "CSV", value: "csv" },
|
||||
{ label: "Excel", value: "xlsx" }
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="chapter" label="章节筛选">
|
||||
<Select allowClear placeholder="不选则导出全部" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button type="primary" onClick={onExport}>开始导出</Button>
|
||||
<Button onClick={downloadTemplate}>下载导入模板</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
92
frontend/src/pages/Import.tsx
Normal file
92
frontend/src/pages/Import.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Button, Card, Table, Tabs, Upload, message } from "antd";
|
||||
import type { UploadProps } from "antd";
|
||||
import { useState } from "react";
|
||||
import api from "../api";
|
||||
|
||||
export default function ImportPage() {
|
||||
const [preview, setPreview] = useState<Record<string, string>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const aiProps: UploadProps = {
|
||||
name: "file",
|
||||
customRequest: async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file as File);
|
||||
const { data } = await api.post("/import/ai/parse", formData);
|
||||
setPreview(data.preview || []);
|
||||
message.success("AI 解析完成");
|
||||
onSuccess?.({});
|
||||
} catch (err) {
|
||||
onError?.(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const excelProps: UploadProps = {
|
||||
name: "file",
|
||||
customRequest: async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file as File);
|
||||
const { data } = await api.post("/import/excel", formData);
|
||||
message.success(`Excel 导入成功,共 ${data.length} 题`);
|
||||
onSuccess?.({});
|
||||
} catch (err) {
|
||||
onError?.(err as Error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSave = async () => {
|
||||
await api.post("/import/ai/confirm", preview);
|
||||
message.success(`已保存 ${preview.length} 道题`);
|
||||
setPreview([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: "ai",
|
||||
label: "AI 智能导入",
|
||||
children: (
|
||||
<Card>
|
||||
<Upload {...aiProps} showUploadList={false}>
|
||||
<Button type="primary" loading={loading}>上传 PDF/Word 解析</Button>
|
||||
</Upload>
|
||||
<Table
|
||||
style={{ marginTop: 16 }}
|
||||
rowKey={(_, index) => String(index)}
|
||||
dataSource={preview}
|
||||
columns={[
|
||||
{ title: "题干", dataIndex: "stem" },
|
||||
{ title: "答案", dataIndex: "answer", width: 120 },
|
||||
{ title: "题型", dataIndex: "question_type", width: 120 }
|
||||
]}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
<Button type="primary" onClick={confirmSave} disabled={!preview.length}>
|
||||
确认保存
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "excel",
|
||||
label: "Excel 导入",
|
||||
children: (
|
||||
<Card>
|
||||
<Upload {...excelProps} showUploadList={false}>
|
||||
<Button type="primary">上传 Excel 导入</Button>
|
||||
</Upload>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
71
frontend/src/pages/Practice.tsx
Normal file
71
frontend/src/pages/Practice.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Button, Card, Form, Input, Radio, Space, message } from "antd";
|
||||
import { useState } from "react";
|
||||
import api from "../api";
|
||||
import LatexRenderer from "../components/LatexRenderer";
|
||||
|
||||
interface PracticeItem {
|
||||
id: number;
|
||||
stem: string;
|
||||
option_a: string;
|
||||
option_b: string;
|
||||
option_c: string;
|
||||
option_d: string;
|
||||
}
|
||||
|
||||
export default function PracticePage() {
|
||||
const [form] = Form.useForm();
|
||||
const [items, setItems] = useState<PracticeItem[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
|
||||
const start = async () => {
|
||||
const { data } = await api.post("/practice/start", form.getFieldsValue());
|
||||
setItems(data.items || []);
|
||||
setIndex(0);
|
||||
setFeedback("");
|
||||
};
|
||||
|
||||
const check = async (answer: string) => {
|
||||
if (!items[index]) return;
|
||||
const { data } = await api.post("/practice/check", {
|
||||
question_id: items[index].id,
|
||||
user_answer: answer
|
||||
});
|
||||
setFeedback(data.correct ? `正确。解析:${data.explanation || ""}` : `错误,答案 ${data.right_answer}。解析:${data.explanation || ""}`);
|
||||
if (data.correct) message.success("回答正确");
|
||||
};
|
||||
|
||||
const current = items[index];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card title="练习配置">
|
||||
<Form form={form} layout="inline">
|
||||
<Form.Item name="chapter" label="章节"><Input /></Form.Item>
|
||||
<Form.Item name="question_type" label="题型"><Input /></Form.Item>
|
||||
<Form.Item name="difficulty" label="难度"><Input /></Form.Item>
|
||||
<Button type="primary" onClick={start}>开始练习</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title={current ? `第 ${index + 1} 题` : "暂无题目"}>
|
||||
{current ? (
|
||||
<>
|
||||
<p><LatexRenderer text={current.stem} /></p>
|
||||
<Radio.Group onChange={(e) => check(e.target.value)}>
|
||||
{current.option_a && <Radio value="A">A. {current.option_a}</Radio>}
|
||||
{current.option_b && <Radio value="B">B. {current.option_b}</Radio>}
|
||||
{current.option_c && <Radio value="C">C. {current.option_c}</Radio>}
|
||||
{current.option_d && <Radio value="D">D. {current.option_d}</Radio>}
|
||||
</Radio.Group>
|
||||
<p style={{ marginTop: 12 }}>{feedback}</p>
|
||||
<Button disabled={index >= items.length - 1} onClick={() => { setIndex((v) => v + 1); setFeedback(""); }}>
|
||||
下一题
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p>请先选择条件并开始练习。</p>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
51
frontend/src/pages/QuestionEdit.tsx
Normal file
51
frontend/src/pages/QuestionEdit.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Button, Card, Form, Input, Select, message } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api from "../api";
|
||||
|
||||
export default function QuestionEdit() {
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || id === "new") return;
|
||||
api.get(`/questions/id/${id}`).then((res) => form.setFieldsValue(res.data));
|
||||
}, [id, form]);
|
||||
|
||||
const onSubmit = async (values: Record<string, string>) => {
|
||||
if (!id || id === "new") {
|
||||
await api.post("/questions", values);
|
||||
message.success("新增成功");
|
||||
} else {
|
||||
await api.put(`/questions/${id}`, values);
|
||||
message.success("保存成功");
|
||||
}
|
||||
navigate("/questions");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={id === "new" ? "新增题目" : "编辑题目"}>
|
||||
<Form form={form} layout="vertical" onFinish={onSubmit}>
|
||||
<Form.Item name="chapter" label="章节"><Input /></Form.Item>
|
||||
<Form.Item name="primary_knowledge" label="一级知识点"><Input /></Form.Item>
|
||||
<Form.Item name="secondary_knowledge" label="二级知识点"><Input /></Form.Item>
|
||||
<Form.Item name="question_type" label="题型">
|
||||
<Select options={["单选", "多选", "不定项", "填空", "解答"].map((v) => ({ value: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="difficulty" label="难度">
|
||||
<Select options={["易", "中", "难"].map((v) => ({ value: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="stem" label="题干" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
|
||||
<Form.Item name="option_a" label="选项A"><Input /></Form.Item>
|
||||
<Form.Item name="option_b" label="选项B"><Input /></Form.Item>
|
||||
<Form.Item name="option_c" label="选项C"><Input /></Form.Item>
|
||||
<Form.Item name="option_d" label="选项D"><Input /></Form.Item>
|
||||
<Form.Item name="answer" label="答案"><Input /></Form.Item>
|
||||
<Form.Item name="explanation" label="解析"><Input.TextArea rows={3} /></Form.Item>
|
||||
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">保存</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/Questions.tsx
Normal file
119
frontend/src/pages/Questions.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
message
|
||||
} from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../api";
|
||||
import LatexRenderer from "../components/LatexRenderer";
|
||||
import { Question } from "../types";
|
||||
|
||||
export default function Questions() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<Question[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [detail, setDetail] = useState<Question | null>(null);
|
||||
const [meta, setMeta] = useState({
|
||||
chapters: [] as string[],
|
||||
secondary_knowledge_list: [] as string[],
|
||||
question_types: [] as string[],
|
||||
difficulties: [] as string[]
|
||||
});
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const values = form.getFieldsValue();
|
||||
const { data } = await api.get("/questions", { params: { page: 1, page_size: 50, ...values } });
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
api.get("/questions/meta/options").then((res) => setMeta(res.data));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const batchDelete = async () => {
|
||||
await api.delete("/questions/batch", { data: { ids: selectedRowKeys } });
|
||||
message.success("批量删除成功");
|
||||
setSelectedRowKeys([]);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" onClick={() => navigate("/questions/new")}>新增题目</Button>
|
||||
<Button danger disabled={!selectedRowKeys.length} onClick={batchDelete}>批量删除</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" onFinish={fetchData} style={{ marginBottom: 12 }}>
|
||||
<Form.Item name="keyword">
|
||||
<Input placeholder="关键词" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="chapter">
|
||||
<Select placeholder="章节" allowClear style={{ width: 150 }} options={meta.chapters.map((v) => ({ value: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="question_type">
|
||||
<Select placeholder="题型" allowClear style={{ width: 130 }} options={meta.question_types.map((v) => ({ value: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="difficulty">
|
||||
<Select placeholder="难度" allowClear style={{ width: 100 }} options={meta.difficulties.map((v) => ({ value: v }))} />
|
||||
</Form.Item>
|
||||
<Button htmlType="submit">查询</Button>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 80 },
|
||||
{ title: "章节", dataIndex: "chapter", width: 120 },
|
||||
{ title: "题型", dataIndex: "question_type", width: 100, render: (v) => <Tag>{v}</Tag> },
|
||||
{ title: "难度", dataIndex: "difficulty", width: 80 },
|
||||
{ title: "题干", dataIndex: "stem", render: (v) => <LatexRenderer text={v} /> },
|
||||
{
|
||||
title: "操作",
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => setDetail(row)}>详情</Button>
|
||||
<Button size="small" onClick={() => navigate(`/questions/${row.id}`)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={async () => { await api.delete(`/questions/${row.id}`); fetchData(); }}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]}
|
||||
pagination={{ total, pageSize: 50 }}
|
||||
/>
|
||||
<Drawer title="题目详情" open={!!detail} onClose={() => setDetail(null)} width={700}>
|
||||
{detail && (
|
||||
<div>
|
||||
<p><b>题干:</b> <LatexRenderer text={detail.stem} /></p>
|
||||
<p>A: <LatexRenderer text={detail.option_a} /></p>
|
||||
<p>B: <LatexRenderer text={detail.option_b} /></p>
|
||||
<p>C: <LatexRenderer text={detail.option_c} /></p>
|
||||
<p>D: <LatexRenderer text={detail.option_d} /></p>
|
||||
<p><b>答案:</b> {detail.answer}</p>
|
||||
<p><b>解析:</b> <LatexRenderer text={detail.explanation} /></p>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/styles.css
Normal file
29
frontend/src/styles.css
Normal file
@@ -0,0 +1,29 @@
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
28
frontend/src/types.ts
Normal file
28
frontend/src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface Question {
|
||||
id: number;
|
||||
chapter: string;
|
||||
primary_knowledge: string;
|
||||
secondary_knowledge: string;
|
||||
question_type: string;
|
||||
difficulty: string;
|
||||
stem: string;
|
||||
option_a: string;
|
||||
option_b: string;
|
||||
option_c: string;
|
||||
option_d: string;
|
||||
answer: string;
|
||||
explanation: string;
|
||||
notes: string;
|
||||
source_file: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CategoryNode {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
parent_id?: number;
|
||||
count?: number;
|
||||
children?: CategoryNode[];
|
||||
}
|
||||
3
frontend/src/utils/latex.ts
Normal file
3
frontend/src/utils/latex.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function normalizeLatexInput(input: string): string {
|
||||
return input.trim();
|
||||
}
|
||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
frontend/vite.config.ts
Normal file
9
frontend/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user