feat: add new file
This commit is contained in:
680
frontend/src/App.jsx
Normal file
680
frontend/src/App.jsx
Normal file
@@ -0,0 +1,680 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Line, LineChart, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import { BookOpen, Bot, ChartNoAxesCombined, NotebookPen } from "lucide-react";
|
||||
|
||||
const api = axios.create({ baseURL: "/" });
|
||||
|
||||
const CATEGORIES = ["常识", "数量关系", "言语理解", "判断推理", "资料分析"];
|
||||
const MISTAKE_CATEGORIES = ["常识", "言语", "数量", "判断", "资料", "科学", "其他"];
|
||||
|
||||
function formatDate(date) {
|
||||
return new Date(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getApiErrorMessage(error, fallback = "请求失败,请稍后重试") {
|
||||
return error?.response?.data?.detail || error?.message || fallback;
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [message, setMessage] = useState("");
|
||||
const show = (msg) => {
|
||||
setMessage(msg);
|
||||
setTimeout(() => setMessage(""), 2500);
|
||||
};
|
||||
return { message, show };
|
||||
}
|
||||
|
||||
function ResourceModule() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [filters, setFilters] = useState({ q: "", category: "", tags: "", resource_type: "", sort_by: "created_at", order: "desc" });
|
||||
const [batch, setBatch] = useState({ category: CATEGORIES[0], tags: "" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const { message, show } = useToast();
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
resource_type: "link",
|
||||
url: "",
|
||||
file_name: "",
|
||||
category: CATEGORIES[0],
|
||||
tags: ""
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await api.get("/api/resources", { params: filters });
|
||||
setItems(res.data);
|
||||
setSelected((prev) => prev.filter((id) => res.data.some((x) => x.id === id)));
|
||||
} catch (error) {
|
||||
show(getApiErrorMessage(error, "加载资源失败"));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [filters.category, filters.order, filters.q, filters.resource_type, filters.sort_by, filters.tags]);
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await api.post("/api/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
setForm((prev) => ({ ...prev, resource_type: "file", url: res.data.url, file_name: res.data.original_name || file.name }));
|
||||
show("文件上传成功");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (form.resource_type === "link" && !form.url) return show("链接类型需填写 URL");
|
||||
if (form.resource_type === "file" && !form.url) return show("文件类型请先上传文件");
|
||||
try {
|
||||
await api.post("/api/resources", form);
|
||||
setForm({ ...form, title: "", url: "", file_name: "", tags: "" });
|
||||
show("资源保存成功");
|
||||
load();
|
||||
} catch (error) {
|
||||
show(getApiErrorMessage(error, "保存资源失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const editItem = async (item) => {
|
||||
const title = window.prompt("修改资源标题", item.title);
|
||||
if (!title) return;
|
||||
await api.put(`/api/resources/${item.id}`, { ...item, title });
|
||||
show("资源已更新");
|
||||
load();
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!window.confirm("确认删除该资源?")) return;
|
||||
await api.delete(`/api/resources/${id}`);
|
||||
show("资源已删除");
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleSelected = (id) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const batchUpdate = async () => {
|
||||
if (!selected.length) return show("请先勾选资源");
|
||||
await api.patch("/api/resources/batch", { ids: selected, category: batch.category, tags: batch.tags || null });
|
||||
show("批量更新成功");
|
||||
load();
|
||||
};
|
||||
|
||||
const batchDelete = async () => {
|
||||
if (!selected.length) return show("请先勾选资源");
|
||||
if (!window.confirm(`确认批量删除 ${selected.length} 条资源?`)) return;
|
||||
await api.post("/api/resources/batch-delete", { ids: selected });
|
||||
show("批量删除成功");
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="card" onSubmit={submit}>
|
||||
<h3>新增资源</h3>
|
||||
<div className="row">
|
||||
<input placeholder="资源标题" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required />
|
||||
<select value={form.resource_type} onChange={(e) => setForm({ ...form, resource_type: e.target.value })}>
|
||||
<option value="link">链接</option>
|
||||
<option value="file">文件</option>
|
||||
</select>
|
||||
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder="链接地址(链接类型必填)" value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} />
|
||||
<input placeholder="文件名(文件类型选填)" value={form.file_name} onChange={(e) => setForm({ ...form, file_name: e.target.value })} />
|
||||
<input placeholder="标签,逗号分隔" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<input type="file" onChange={(e) => uploadFile(e.target.files?.[0])} />
|
||||
<span className="muted">{uploading ? "上传中..." : "支持 PDF/Word/JPG/PNG/WebP,<=50MB"}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="primary" type="submit">
|
||||
保存资源
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<h3>检索与批量操作</h3>
|
||||
<div className="row">
|
||||
<input placeholder="搜索标题/标签/链接关键词" value={filters.q} onChange={(e) => setFilters({ ...filters, q: e.target.value })} />
|
||||
<select value={filters.category} onChange={(e) => setFilters({ ...filters, category: e.target.value })}>
|
||||
<option value="">全部分类</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder="按标签筛选" value={filters.tags} onChange={(e) => setFilters({ ...filters, tags: e.target.value })} />
|
||||
<select value={filters.resource_type} onChange={(e) => setFilters({ ...filters, resource_type: e.target.value })}>
|
||||
<option value="">全部类型</option>
|
||||
<option value="link">链接</option>
|
||||
<option value="file">文件</option>
|
||||
</select>
|
||||
<select value={filters.sort_by} onChange={(e) => setFilters({ ...filters, sort_by: e.target.value })}>
|
||||
<option value="created_at">按创建时间</option>
|
||||
<option value="name">按名称</option>
|
||||
</select>
|
||||
<select value={filters.order} onChange={(e) => setFilters({ ...filters, order: e.target.value })}>
|
||||
<option value="desc">降序</option>
|
||||
<option value="asc">升序</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<select value={batch.category} onChange={(e) => setBatch({ ...batch, category: e.target.value })}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder="批量标签(覆盖)" value={batch.tags} onChange={(e) => setBatch({ ...batch, tags: e.target.value })} />
|
||||
<button className="primary" type="button" onClick={batchUpdate}>
|
||||
批量分类/打标签
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="danger" type="button" onClick={batchDelete}>
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>资源列表</h3>
|
||||
{items.map((item) => (
|
||||
<div className="list-item" key={item.id}>
|
||||
<div>
|
||||
<input type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
|
||||
<div>{item.title}</div>
|
||||
<div className="muted">
|
||||
{item.category} | {item.resource_type} | {item.tags || "无标签"}
|
||||
</div>
|
||||
{item.url && (
|
||||
<div>
|
||||
<a href={item.url} target="_blank" rel="noreferrer">
|
||||
打开资源
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-group">
|
||||
<button className="primary" onClick={() => editItem(item)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="danger" onClick={() => remove(item.id)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="muted">暂无数据</div>}
|
||||
</div>
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MistakeModule() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [analysis, setAnalysis] = useState("");
|
||||
const [filters, setFilters] = useState({ category: "", keyword: "", sort_by: "created_at", order: "desc" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const { message, show } = useToast();
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
image_url: "",
|
||||
category: MISTAKE_CATEGORIES[0],
|
||||
difficulty: "medium",
|
||||
note: "",
|
||||
wrong_count: 1
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.get("/api/mistakes", { params: filters });
|
||||
setItems(res.data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [filters.category, filters.keyword, filters.order, filters.sort_by]);
|
||||
|
||||
const uploadImage = async (file) => {
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await api.post("/api/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
setForm((prev) => ({ ...prev, image_url: res.data.url }));
|
||||
show("错题图片上传成功");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
await api.post("/api/mistakes", form);
|
||||
setForm({ ...form, title: "", image_url: "", note: "" });
|
||||
show("错题保存成功");
|
||||
load();
|
||||
};
|
||||
|
||||
const editItem = async (item) => {
|
||||
const note = window.prompt("修改错题备注", item.note || "");
|
||||
if (note === null) return;
|
||||
await api.put(`/api/mistakes/${item.id}`, { ...item, note });
|
||||
show("错题已更新");
|
||||
load();
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!window.confirm("确认删除该错题?")) return;
|
||||
await api.delete(`/api/mistakes/${id}`);
|
||||
show("错题已删除");
|
||||
load();
|
||||
};
|
||||
|
||||
const exportFile = (type) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
window.open(`/api/mistakes/export/${type}?${params.toString()}`, "_blank");
|
||||
};
|
||||
|
||||
const askAi = async () => {
|
||||
if (!selectedId) return show("请先选择一条错题");
|
||||
try {
|
||||
const res = await api.post(`/api/ai/mistakes/${selectedId}/analyze`);
|
||||
setAnalysis(res.data.analysis);
|
||||
} catch (error) {
|
||||
show(getApiErrorMessage(error, "AI 错题解析失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="card" onSubmit={submit}>
|
||||
<h3>新增错题</h3>
|
||||
<div className="row">
|
||||
<input placeholder="题目标题" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required />
|
||||
<input placeholder="图片 URL(可选,上传后自动填充)" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
|
||||
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{MISTAKE_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={form.difficulty} onChange={(e) => setForm({ ...form, difficulty: e.target.value })}>
|
||||
<option value="easy">易</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="hard">难</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.wrong_count}
|
||||
onChange={(e) => setForm({ ...form, wrong_count: Number(e.target.value || 1) })}
|
||||
/>
|
||||
<textarea placeholder="知识点备注(最多500字)" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<input type="file" accept="image/*" onChange={(e) => uploadImage(e.target.files?.[0])} />
|
||||
<span className="muted">{uploading ? "上传中..." : "支持错题图片上传与替换"}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="primary" type="submit">
|
||||
保存错题
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<h3>错题筛选 / 导出 / AI</h3>
|
||||
<div className="row">
|
||||
<select value={filters.category} onChange={(e) => setFilters({ ...filters, category: e.target.value })}>
|
||||
<option value="">全部分类</option>
|
||||
{MISTAKE_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder="按知识点关键词检索" value={filters.keyword} onChange={(e) => setFilters({ ...filters, keyword: e.target.value })} />
|
||||
<select value={filters.sort_by} onChange={(e) => setFilters({ ...filters, sort_by: e.target.value })}>
|
||||
<option value="created_at">按录入时间</option>
|
||||
<option value="wrong_count">按错误频次</option>
|
||||
</select>
|
||||
<select value={filters.order} onChange={(e) => setFilters({ ...filters, order: e.target.value })}>
|
||||
<option value="desc">降序</option>
|
||||
<option value="asc">升序</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }} className="action-group">
|
||||
<button className="primary" type="button" onClick={() => exportFile("pdf")}>
|
||||
导出 PDF
|
||||
</button>
|
||||
<button className="primary" type="button" onClick={() => exportFile("docx")}>
|
||||
导出 Word
|
||||
</button>
|
||||
<button className="primary" type="button" onClick={askAi}>
|
||||
AI 错题解析(千问)
|
||||
</button>
|
||||
</div>
|
||||
{analysis && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<h4>AI 解析结果</h4>
|
||||
<pre className="pre-wrap">{analysis}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>错题列表</h3>
|
||||
{items.map((item) => (
|
||||
<div className={`list-item ${selectedId === item.id ? "selected" : ""}`} key={item.id}>
|
||||
<div>
|
||||
<input type="radio" name="pickedMistake" checked={selectedId === item.id} onChange={() => setSelectedId(item.id)} />
|
||||
<div>{item.title}</div>
|
||||
<div className="muted">
|
||||
{item.category} | {item.difficulty || "无难度"} | 错误频次 {item.wrong_count}
|
||||
</div>
|
||||
{item.image_url && (
|
||||
<div>
|
||||
<a href={item.image_url} target="_blank" rel="noreferrer">
|
||||
查看错题图片
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-group">
|
||||
<button className="primary" onClick={() => editItem(item)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="danger" onClick={() => remove(item.id)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="muted">暂无数据</div>}
|
||||
</div>
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreModule() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [stats, setStats] = useState({ highest: 0, lowest: 0, average: 0, improvement: 0 });
|
||||
const [filters, setFilters] = useState({ start_date: "", end_date: "" });
|
||||
const { message, show } = useToast();
|
||||
const [form, setForm] = useState({
|
||||
exam_name: "",
|
||||
exam_date: new Date().toISOString().slice(0, 10),
|
||||
total_score: 100,
|
||||
module_scores: ""
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
const scoreParams = {};
|
||||
if (filters.start_date) scoreParams.start_date = filters.start_date;
|
||||
if (filters.end_date) scoreParams.end_date = filters.end_date;
|
||||
try {
|
||||
const [scoresRes, statsRes] = await Promise.all([api.get("/api/scores", { params: scoreParams }), api.get("/api/scores/stats")]);
|
||||
setItems(scoresRes.data);
|
||||
setStats(statsRes.data);
|
||||
} catch (error) {
|
||||
show(getApiErrorMessage(error, "加载成绩数据失败"));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [filters.end_date, filters.start_date]);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.post("/api/scores", { ...form, total_score: Number(form.total_score) });
|
||||
setForm({ ...form, exam_name: "", total_score: 100, module_scores: "" });
|
||||
show("成绩记录已保存");
|
||||
load();
|
||||
} catch (error) {
|
||||
show(getApiErrorMessage(error, "保存成绩失败"));
|
||||
}
|
||||
};
|
||||
|
||||
const editItem = async (item) => {
|
||||
const score = Number(window.prompt("修改总分(0-200)", String(item.total_score)));
|
||||
if (Number.isNaN(score)) return;
|
||||
await api.put(`/api/scores/${item.id}`, { ...item, total_score: score });
|
||||
show("成绩已更新");
|
||||
load();
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!window.confirm("确认删除该记录?")) return;
|
||||
await api.delete(`/api/scores/${id}`);
|
||||
show("成绩已删除");
|
||||
load();
|
||||
};
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
date: item.exam_date,
|
||||
score: item.total_score
|
||||
})),
|
||||
[items]
|
||||
);
|
||||
|
||||
const quickRange = (days) => {
|
||||
const end = new Date();
|
||||
const start = new Date(Date.now() - days * 24 * 3600 * 1000);
|
||||
setFilters({ start_date: formatDate(start), end_date: formatDate(end) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="card" onSubmit={submit}>
|
||||
<h3>新增模考记录</h3>
|
||||
<div className="row">
|
||||
<input placeholder="考试名称" value={form.exam_name} onChange={(e) => setForm({ ...form, exam_name: e.target.value })} required />
|
||||
<input type="date" value={form.exam_date} onChange={(e) => setForm({ ...form, exam_date: e.target.value })} required />
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={200}
|
||||
value={form.total_score}
|
||||
onChange={(e) => setForm({ ...form, total_score: Number(e.target.value || 0) })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="模块分数字符串(如 常识:20,言语:35)"
|
||||
value={form.module_scores}
|
||||
onChange={(e) => setForm({ ...form, module_scores: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="primary" type="submit">
|
||||
保存成绩
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<h3>时间筛选</h3>
|
||||
<div className="row">
|
||||
<input type="date" value={filters.start_date} onChange={(e) => setFilters({ ...filters, start_date: e.target.value })} />
|
||||
<input type="date" value={filters.end_date} onChange={(e) => setFilters({ ...filters, end_date: e.target.value })} />
|
||||
<button className="primary" type="button" onClick={() => quickRange(7)}>
|
||||
近 7 天
|
||||
</button>
|
||||
<button className="primary" type="button" onClick={() => quickRange(30)}>
|
||||
近 30 天
|
||||
</button>
|
||||
<button className="primary" type="button" onClick={() => setFilters({ start_date: "", end_date: "" })}>
|
||||
清空筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>分数趋势</h3>
|
||||
<div style={{ width: "100%", height: 260 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis domain={[0, 200]} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="score" stroke="#2563eb" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="muted">
|
||||
最高分: {stats.highest} | 最低分: {stats.lowest} | 平均分: {stats.average} | 提升分: {stats.improvement}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>成绩记录</h3>
|
||||
{items.map((item) => (
|
||||
<div className="list-item" key={item.id}>
|
||||
<div>
|
||||
<div>{item.exam_name}</div>
|
||||
<div className="muted">
|
||||
{item.exam_date} | {item.total_score} 分
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-group">
|
||||
<button className="primary" onClick={() => editItem(item)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="danger" onClick={() => remove(item.id)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="muted">暂无数据</div>}
|
||||
</div>
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AiModule() {
|
||||
const { message, show } = useToast();
|
||||
const [form, setForm] = useState({ goal: "30天内行测稳定到70分以上", days_left: 30, daily_hours: 2 });
|
||||
const [plan, setPlan] = useState("");
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await api.post("/api/ai/study-plan", form);
|
||||
setPlan(res.data.plan);
|
||||
show("学习计划已生成");
|
||||
} catch (error) {
|
||||
show(getApiErrorMessage(error, "AI 学习计划生成失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="card" onSubmit={submit}>
|
||||
<h3>AI 学习计划(千问)</h3>
|
||||
<div className="row">
|
||||
<input value={form.goal} onChange={(e) => setForm({ ...form, goal: e.target.value })} placeholder="目标" />
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={form.days_left}
|
||||
onChange={(e) => setForm({ ...form, days_left: Number(e.target.value || 30) })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={16}
|
||||
step={0.5}
|
||||
value={form.daily_hours}
|
||||
onChange={(e) => setForm({ ...form, daily_hours: Number(e.target.value || 2) })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="primary" type="submit">
|
||||
生成学习计划
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="card">
|
||||
<h3>计划输出</h3>
|
||||
<pre className="pre-wrap">{plan || "暂无结果。请先生成计划。"}</pre>
|
||||
</div>
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeTab, setActiveTab] = useState("resource");
|
||||
const tabs = [
|
||||
{ key: "resource", label: "资源", icon: BookOpen },
|
||||
{ key: "mistake", label: "错题", icon: NotebookPen },
|
||||
{ key: "score", label: "过程", icon: ChartNoAxesCombined },
|
||||
{ key: "ai", label: "AI", icon: Bot }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="container">
|
||||
<div className="app-header">
|
||||
<h1>公考助手</h1>
|
||||
<p className="muted">移动版 · 支持 WebView 套壳</p>
|
||||
</div>
|
||||
<div className="tabs tabs-top">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.key} className={activeTab === tab.key ? "active" : ""} onClick={() => setActiveTab(tab.key)} aria-label={tab.label}>
|
||||
<tab.icon size={16} /> {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeTab === "resource" && <ResourceModule />}
|
||||
{activeTab === "mistake" && <MistakeModule />}
|
||||
{activeTab === "score" && <ScoreModule />}
|
||||
{activeTab === "ai" && <AiModule />}
|
||||
</div>
|
||||
|
||||
<div className="tabs tabs-bottom">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.key} className={activeTab === tab.key ? "active" : ""} onClick={() => setActiveTab(tab.key)} aria-label={tab.label}>
|
||||
<span className="tab-icon-wrap">
|
||||
<tab.icon size={18} />
|
||||
</span>
|
||||
<small>{tab.label}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
296
frontend/src/styles.css
Normal file
296
frontend/src/styles.css
Normal file
@@ -0,0 +1,296 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: radial-gradient(circle at top, #1e3a8a 0%, #0b1223 40%, #050914 100%);
|
||||
color: #e5e7eb;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
min-height: 100vh;
|
||||
padding-bottom: calc(82px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 14px 12px 20px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(3, 7, 18, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #f9fafb;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
padding: 10px 11px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
background: linear-gradient(120deg, #2563eb, #4f46e5);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tabs-top {
|
||||
display: none;
|
||||
margin-bottom: 14px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs-top button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tabs-bottom {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
background: rgba(2, 6, 23, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tabs-bottom button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px 4px;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.tabs-bottom small {
|
||||
font-size: 11px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.tab-icon-wrap {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tabs-bottom button.active .tab-icon-wrap {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.86);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 8px 22px rgba(2, 6, 23, 0.32);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 12px;
|
||||
padding: 11px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: linear-gradient(120deg, #2563eb, #4f46e5);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
min-height: 42px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: linear-gradient(120deg, #ef4444, #dc2626);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (min-width: 861px) {
|
||||
.app-page {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.tabs-bottom {
|
||||
display: none;
|
||||
}
|
||||
.tabs-top {
|
||||
display: flex;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.container {
|
||||
padding: 10px 10px 16px;
|
||||
}
|
||||
.app-header {
|
||||
position: static;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card {
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
.action-group {
|
||||
width: 100%;
|
||||
}
|
||||
.action-group button {
|
||||
flex: 1;
|
||||
}
|
||||
.list-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
.tabs-top {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user