fix: 优化整个项目内容

This commit is contained in:
Daniel
2026-04-19 15:09:22 +08:00
parent 019963abd6
commit 06af48f560
9 changed files with 299 additions and 257 deletions

View File

@@ -62,7 +62,7 @@ def _migrate_mistake_columns() -> None:
_migrate_mistake_columns()
app = FastAPI(title="公考助手 API", version="1.0.0")
app = FastAPI(title="学习伙伴 API", version="1.0.0")
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/app/uploads"))
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
@@ -273,9 +273,23 @@ def _restore_upload_url_from_zip(url: str | None, zip_ref: zipfile.ZipFile) -> s
@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
suffix = Path(file.filename or "").suffix.lower()
allowed = {".pdf", ".doc", ".docx", ".jpg", ".jpeg", ".png", ".webp"}
allowed = {".pdf", ".doc", ".docx", ".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
mime_to_suffix = {
"application/pdf": ".pdf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/heic": ".heic",
"image/heif": ".heif",
}
if suffix not in allowed:
raise HTTPException(status_code=400, detail="不支持的文件类型")
guessed = mime_to_suffix.get((file.content_type or "").lower())
if guessed:
suffix = guessed
else:
raise HTTPException(status_code=400, detail="不支持的文件类型")
content = await file.read()
if len(content) > 50 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件不能超过 50MB")
@@ -452,7 +466,7 @@ def export_mistakes_pdf(
left = 48
right = 560
max_width = right - left
pdf.drawString(left, y, "公考助手 - 错题导出")
pdf.drawString(left, y, "学习伙伴 - 错题导出")
y -= 28
for idx, item in enumerate(items, start=1):
if y < 90:
@@ -497,7 +511,7 @@ def export_mistakes_docx(
id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()] if ids else None
items = _query_mistakes_for_export(db, category, start_date, end_date, id_list)
doc = Document()
doc.add_heading("公考助手 - 错题导出", level=1)
doc.add_heading("学习伙伴 - 错题导出", level=1)
for idx, item in enumerate(items, start=1):
blocks = _mistake_export_blocks(item, content_mode)
for bi, block in enumerate(blocks):
@@ -934,9 +948,11 @@ async def parse_ocr(payload: OcrParseIn):
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
}.get(suffix)
if not mime:
raise HTTPException(status_code=400, detail="仅支持 JPG/PNG/WebP OCR")
raise HTTPException(status_code=400, detail="仅支持 JPG/PNG/WebP/HEIC OCR")
b64 = base64.b64encode(target.read_bytes()).decode("utf-8")
image_data_url = f"data:{mime};base64,{b64}"
@@ -952,7 +968,7 @@ async def parse_ocr(payload: OcrParseIn):
ocr_prompt = f"{ocr_prompt}\n补充要求:{payload.prompt}"
raw_text = await _call_qwen_vision(
"你是公考题目OCR与结构化助手。输出必须是 JSON不要额外解释。",
"你是题目 OCR 与结构化助手。输出必须是 JSON不要额外解释。",
ocr_prompt,
image_data_url,
)
@@ -1033,7 +1049,7 @@ async def ai_analyze_mistake(item_id: int, db: Session = Depends(get_db)):
if not item:
raise HTTPException(status_code=404, detail="Mistake not found")
content = await _call_qwen(
"你是公考备考教练,请输出结构化、可执行的错题分析。",
"你是学习教练,请输出结构化、可执行的错题分析。",
(
f"错题标题: {item.title}\n"
f"分类: {item.category}\n"
@@ -1059,7 +1075,7 @@ async def ai_study_plan(payload: AiStudyPlanIn, db: Session = Depends(get_db)):
mistake_text = ", ".join([f"{m.category}-{m.title}" for m in recent_mistakes]) or "暂无错题数据"
content = await _call_qwen(
"你是公考学习规划师,请给出可执行计划并尽量量化。",
"你是学习规划师,请给出可执行计划并尽量量化。",
(
f"目标: {payload.goal}\n"
f"剩余天数: {payload.days_left}\n"

File diff suppressed because one or more lines are too long

192
frontend/dist/assets/index-DyP_J9zM.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -8,8 +8,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>公考助手</title>
<script type="module" crossorigin src="/assets/index-B6wMcdCx.js"></script>
<title>学习伙伴</title>
<script type="module" crossorigin src="/assets/index-DyP_J9zM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-dSv0ph.css">
</head>
<body>

View File

@@ -1,7 +1,7 @@
{
"name": "公考助手",
"short_name": "公考助手",
"description": "公考学习资源、错题与过程管理",
"name": "学习伙伴",
"short_name": "学习伙伴",
"description": "学习资源、错题与过程管理",
"display": "standalone",
"background_color": "#e8ecf1",
"theme_color": "#2563eb",

View File

@@ -8,7 +8,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>公考助手</title>
<title>学习伙伴</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,7 +1,7 @@
{
"name": "公考助手",
"short_name": "公考助手",
"description": "公考学习资源、错题与过程管理",
"name": "学习伙伴",
"short_name": "学习伙伴",
"description": "学习资源、错题与过程管理",
"display": "standalone",
"background_color": "#e8ecf1",
"theme_color": "#2563eb",

View File

@@ -614,9 +614,7 @@ function ResourceModule() {
function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const [items, setItems] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [selectedExportIds, setSelectedExportIds] = useState([]);
const [analysis, setAnalysis] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const [keyword, setKeyword] = useState("");
const [sortKey, setSortKey] = useState("time_desc");
@@ -688,9 +686,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const res = await api.get("/api/mistakes", { params: apiFilters });
setItems(res.data);
setSelectedExportIds((prev) => prev.filter((id) => res.data.some((x) => x.id === id)));
if (selectedId && !res.data.some((x) => x.id === selectedId)) {
setSelectedId(null);
}
setDetailItem((prev) => (prev && !res.data.some((x) => x.id === prev.id) ? null : prev));
} catch (error) {
show(getApiErrorMessage(error, "加载错题失败"));
}
@@ -734,6 +730,56 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
runOcr(imageUrl).catch(() => {});
};
const normalizeCaptureFile = async (file) => {
if (!file) return file;
const type = String(file.type || "").toLowerCase();
const isAlreadySupported = ["image/jpeg", "image/png", "image/webp"].includes(type);
const hasKnownExt = /\.(jpe?g|png|webp)$/i.test(file.name || "");
if (isAlreadySupported && hasKnownExt) return file;
if (!type.startsWith("image/")) return file;
try {
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取图片失败"));
reader.readAsDataURL(file);
});
const img = await new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图片解码失败"));
image.src = dataUrl;
});
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(img, 0, 0);
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => {
if (!b) reject(new Error("图片转换失败"));
else resolve(b);
},
"image/jpeg",
0.92
);
});
const baseName = String(file.name || "capture").replace(/\.[^.]+$/, "");
return new File([blob], `${baseName || "capture"}-${Date.now()}.jpg`, { type: "image/jpeg" });
} catch {
// If conversion fails, fallback to original file; backend still has suffix+mime fallback.
return file;
}
};
const uploadImageBlob = async (blob, fileName = `scan-${Date.now()}.jpg`, autoParse = true) => {
const fd = new FormData();
fd.append("file", new File([blob], fileName, { type: blob.type || "image/jpeg" }));
@@ -753,8 +799,9 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const uploadImage = async (file, autoParse = true) => {
if (!file) return;
const normalized = await normalizeCaptureFile(file);
const fd = new FormData();
fd.append("file", file);
fd.append("file", normalized);
setUploading(true);
try {
const res = await api.post("/api/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
@@ -771,8 +818,9 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const replaceEditingImage = async (file) => {
if (!file) return;
const normalized = await normalizeCaptureFile(file);
const fd = new FormData();
fd.append("file", file);
fd.append("file", normalized);
setUploading(true);
try {
const res = await api.post("/api/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
@@ -787,8 +835,9 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const uploadImageFileOnly = async (file) => {
if (!file) return "";
const normalized = await normalizeCaptureFile(file);
const fd = new FormData();
fd.append("file", file);
fd.append("file", normalized);
const res = await api.post("/api/upload", fd, { headers: { "Content-Type": "multipart/form-data" } });
return res.data.url;
};
@@ -891,7 +940,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const remove = async (id) => {
if (!window.confirm("确认删除该错题?")) return;
await api.delete(`/api/mistakes/${id}`);
if (selectedId === id) setSelectedId(null);
setDetailItem((prev) => (prev?.id === id ? null : prev));
show("错题已删除");
load();
};
@@ -934,17 +983,6 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
show("已开始下载");
};
const askAi = async () => {
if (!selectedId) return show("请先点击列表中的一条错题");
try {
const res = await api.post(`/api/ai/mistakes/${selectedId}/analyze`);
setAnalysis(res.data.analysis);
show("解析完成");
} catch (error) {
show(getApiErrorMessage(error, "AI 错题解析失败"));
}
};
const toggleExportSelected = (id) => {
setSelectedExportIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
@@ -978,7 +1016,11 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
if (quickCaptureTask.mode === "single") {
setShowAdd(true);
await uploadImage(files[0], true);
try {
await uploadImage(files[0], true);
} catch (error) {
show(getApiErrorMessage(error, "快速拍题上传失败,请重试"));
}
return;
}
@@ -1040,9 +1082,6 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
>
<FileDown size={18} /> 导出错题
</button>
<button type="button" className="btn btn-outline btn-pill" onClick={askAi} disabled={!selectedId}>
AI 解析
</button>
</div>
<div className="toolbar-right">
<select className="select-min" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} aria-label="分类">
@@ -1093,17 +1132,11 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
<div
role="button"
tabIndex={0}
className={`mistake-card ${selectedId === item.id ? "is-selected" : ""}`}
onClick={() => {
setSelectedId(item.id);
setAnalysis("");
setDetailItem(item);
}}
className={`mistake-card ${detailItem?.id === item.id ? "is-selected" : ""}`}
onClick={() => setDetailItem(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedId(item.id);
setAnalysis("");
setDetailItem(item);
}
}}
@@ -1144,13 +1177,6 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
)}
</div>
{analysis && (
<div className="panel ai-result">
<h4 className="panel-subtitle">AI 解析</h4>
<pre className="pre-wrap">{analysis}</pre>
</div>
)}
{showAdd && (
<Modal title="添加错题" onClose={() => setShowAdd(false)}>
<form onSubmit={submit}>
@@ -1753,7 +1779,7 @@ function ScoreModule() {
function AiModule() {
const { message, show } = useToast();
const [form, setForm] = useState({ goal: "30天内行测稳定到70分以上", days_left: 30, daily_hours: 2 });
const [form, setForm] = useState({ goal: "30天内模考成绩稳定达到目标分", days_left: 30, daily_hours: 2 });
const [plan, setPlan] = useState("");
const submit = async (e) => {
@@ -1839,7 +1865,7 @@ export default function App() {
<GraduationCap size={28} strokeWidth={2} />
</span>
<div>
<h1 className="brand-title">公考助手</h1>
<h1 className="brand-title">学习伙伴</h1>
<p className="brand-sub">智能错题整理 · 科学分数管理</p>
</div>
</div>

View File

@@ -1,4 +1,4 @@
# 公考助手(前后端分离 + Docker 一键运行)
# 学习伙伴(前后端分离 + Docker 一键运行)
本项目基于 `readme.md` 中的 PRD 实现了可运行的全栈版本:
- 前端React + Vite + Nginx
@@ -122,13 +122,13 @@ docker compose down -v
- 文件上传MinIO / OSS与预览
- 错题导出 PDF/Word
- 分数筛选(近 7 天 / 近 30 天 / 自定义)
公考助手PRD
学习伙伴PRD
一、文档基础信息
产品名称:公考助手(网页版)
产品名称:学习伙伴(网页版)
文档版本V1.0.0
适用终端PC 网页端Chrome/Edge/Firefox 主流浏览器)
核心定位:一站式公考学习资源管理、错题归集、学习进度可视化工具
目标用户:公职类考试备考人群
核心定位:一站式学习资源管理、错题归集、学习进度可视化工具
目标用户:备考与自主学习人群
二、版本修订记录
版本号
日期
@@ -203,7 +203,7 @@ V1.0.0
3.1 功能概述
记录模考分数,自动生成折线图,可视化展示分数变化趋势。
3.2 模考分数录入
录入字段:考试名称(如 “2026 省考模考 1、考试时间、总分0-200 分)、各模块分数(可选)
录入字段:考试名称(如 “2026 第 1 次模考、考试时间、总分0-200 分)、各模块分数(可选)
校验规则总分≤200非空校验时间不可选未来日期
编辑 / 删除:支持修改分数、删除记录,数据实时更新图表
3.3 数据可视化