first commit

This commit is contained in:
2026-03-05 11:50:15 +08:00
commit b1b14fd964
45 changed files with 7779 additions and 0 deletions

12
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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
View 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
View 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;

View 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) }} />;
}

View 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>
);
}

View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)
}
]}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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[];
}

View File

@@ -0,0 +1,3 @@
export function normalizeLatexInput(input: string): string {
return input.trim();
}

17
frontend/tsconfig.json Normal file
View 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
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
});