fix: 优化整个项目内容
This commit is contained in:
@@ -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"
|
||||
|
||||
192
frontend/dist/assets/index-B6wMcdCx.js
vendored
192
frontend/dist/assets/index-B6wMcdCx.js
vendored
File diff suppressed because one or more lines are too long
192
frontend/dist/assets/index-DyP_J9zM.js
vendored
Normal file
192
frontend/dist/assets/index-DyP_J9zM.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
6
frontend/dist/manifest.webmanifest
vendored
6
frontend/dist/manifest.webmanifest
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "公考助手",
|
||||
"short_name": "公考助手",
|
||||
"description": "公考学习资源、错题与过程管理",
|
||||
"name": "学习伙伴",
|
||||
"short_name": "学习伙伴",
|
||||
"description": "学习资源、错题与过程管理",
|
||||
"display": "standalone",
|
||||
"background_color": "#e8ecf1",
|
||||
"theme_color": "#2563eb",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "公考助手",
|
||||
"short_name": "公考助手",
|
||||
"description": "公考学习资源、错题与过程管理",
|
||||
"name": "学习伙伴",
|
||||
"short_name": "学习伙伴",
|
||||
"description": "学习资源、错题与过程管理",
|
||||
"display": "standalone",
|
||||
"background_color": "#e8ecf1",
|
||||
"theme_color": "#2563eb",
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
readme.md
12
readme.md
@@ -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 数据可视化
|
||||
|
||||
Reference in New Issue
Block a user