fix: 优化个相机功能与样式

This commit is contained in:
Daniel
2026-04-19 15:41:56 +08:00
parent fd8e31adb4
commit aa78763af2
6 changed files with 492 additions and 218 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

204
frontend/dist/assets/index-Dw7fpRnj.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -9,8 +9,8 @@
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<title>学习伙伴</title> <title>学习伙伴</title>
<script type="module" crossorigin src="/assets/index-9iYNjrsg.js"></script> <script type="module" crossorigin src="/assets/index-Dw7fpRnj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-dSv0ph.css"> <link rel="stylesheet" crossorigin href="/assets/index-CoOKrmS9.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -9,6 +9,8 @@ import {
FileDown, FileDown,
FolderOpen, FolderOpen,
GraduationCap, GraduationCap,
Maximize2,
Minimize2,
MoreHorizontal, MoreHorizontal,
NotebookPen, NotebookPen,
Plus, Plus,
@@ -17,6 +19,24 @@ import {
const api = axios.create({ baseURL: "/" }); const api = axios.create({ baseURL: "/" });
const QUICK_CAMERA_FAB_COMPACT_KEY = "studyBuddy_quickCameraFabCompact";
function readQuickCameraFabCompact() {
try {
return localStorage.getItem(QUICK_CAMERA_FAB_COMPACT_KEY) === "1";
} catch {
return false;
}
}
function writeQuickCameraFabCompact(value) {
try {
localStorage.setItem(QUICK_CAMERA_FAB_COMPACT_KEY, value ? "1" : "0");
} catch {
/* ignore */
}
}
const CATEGORIES = ["常识", "数量关系", "言语理解", "判断推理", "资料分析"]; const CATEGORIES = ["常识", "数量关系", "言语理解", "判断推理", "资料分析"];
const MISTAKE_CATEGORIES = ["常识", "言语", "数量", "判断", "资料", "科学", "其他"]; const MISTAKE_CATEGORIES = ["常识", "言语", "数量", "判断", "资料", "科学", "其他"];
@@ -616,6 +636,7 @@ function ResourceModule() {
} }
function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) { function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const PENDING_IMAGE_TITLE = "待补录图片错题";
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [selectedExportIds, setSelectedExportIds] = useState([]); const [selectedExportIds, setSelectedExportIds] = useState([]);
const [categoryFilter, setCategoryFilter] = useState(""); const [categoryFilter, setCategoryFilter] = useState("");
@@ -632,6 +653,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const [exportScope, setExportScope] = useState("all"); const [exportScope, setExportScope] = useState("all");
const [exportContentMode, setExportContentMode] = useState("full"); const [exportContentMode, setExportContentMode] = useState("full");
const [exportRange, setExportRange] = useState({ start_date: "", end_date: "" }); const [exportRange, setExportRange] = useState({ start_date: "", end_date: "" });
const [batchRecognizeScope, setBatchRecognizeScope] = useState("all_pending");
const uploadInputRef = useRef(null); const uploadInputRef = useRef(null);
const captureInputRef = useRef(null); const captureInputRef = useRef(null);
@@ -651,6 +673,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
}); });
const [ocrText, setOcrText] = useState(""); const [ocrText, setOcrText] = useState("");
const [ocrLoading, setOcrLoading] = useState(false); const [ocrLoading, setOcrLoading] = useState(false);
const [batchRecognizing, setBatchRecognizing] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [detailItem, setDetailItem] = useState(null); const [detailItem, setDetailItem] = useState(null);
@@ -660,6 +683,15 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
return source.slice(0, 40); return source.slice(0, 40);
}; };
const isPendingImageTitle = (title) => String(title || "").trim() === PENDING_IMAGE_TITLE;
const resolveTitleAfterOcr = (currentTitle, data) => {
const recognizedTitle = data?.title_suggestion || buildTitleFromQuestion(data?.question_content || data?.text);
const current = String(currentTitle || "").trim();
if (!current || isPendingImageTitle(current)) return recognizedTitle || current || PENDING_IMAGE_TITLE;
return current;
};
/** 模型常把全文放在 textquestion_content 只有短问句;题目内容应取更完整的一份 */ /** 模型常把全文放在 textquestion_content 只有短问句;题目内容应取更完整的一份 */
const mergeQuestionContent = (questionContent, fullText) => { const mergeQuestionContent = (questionContent, fullText) => {
const q = String(questionContent || "").trim(); const q = String(questionContent || "").trim();
@@ -689,7 +721,11 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const res = await api.get("/api/mistakes", { params: apiFilters }); const res = await api.get("/api/mistakes", { params: apiFilters });
setItems(res.data); setItems(res.data);
setSelectedExportIds((prev) => prev.filter((id) => res.data.some((x) => x.id === id))); setSelectedExportIds((prev) => prev.filter((id) => res.data.some((x) => x.id === id)));
setDetailItem((prev) => (prev && !res.data.some((x) => x.id === prev.id) ? null : prev)); setDetailItem((prev) => {
if (!prev) return prev;
const latest = res.data.find((x) => x.id === prev.id);
return latest || null;
});
} catch (error) { } catch (error) {
show(getApiErrorMessage(error, "加载错题失败")); show(getApiErrorMessage(error, "加载错题失败"));
} }
@@ -708,7 +744,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
setOcrText(data.text || ""); setOcrText(data.text || "");
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
title: prev.title || data.title_suggestion || buildTitleFromQuestion(data.question_content || data.text), title: resolveTitleAfterOcr(prev.title, data),
category: MISTAKE_CATEGORIES.includes(data.category_suggestion) ? data.category_suggestion : prev.category, category: MISTAKE_CATEGORIES.includes(data.category_suggestion) ? data.category_suggestion : prev.category,
difficulty: ["easy", "medium", "hard"].includes(data.difficulty_suggestion) ? data.difficulty_suggestion : prev.difficulty, difficulty: ["easy", "medium", "hard"].includes(data.difficulty_suggestion) ? data.difficulty_suggestion : prev.difficulty,
question_content: ( question_content: (
@@ -868,7 +904,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
if (!prev) return prev; if (!prev) return prev;
return { return {
...prev, ...prev,
title: prev.title || data.title_suggestion || buildTitleFromQuestion(data.question_content || data.text), title: resolveTitleAfterOcr(prev.title, data),
category: MISTAKE_CATEGORIES.includes(data.category_suggestion) ? data.category_suggestion : prev.category, category: MISTAKE_CATEGORIES.includes(data.category_suggestion) ? data.category_suggestion : prev.category,
difficulty: ["easy", "medium", "hard"].includes(data.difficulty_suggestion) ? data.difficulty_suggestion : prev.difficulty, difficulty: ["easy", "medium", "hard"].includes(data.difficulty_suggestion) ? data.difficulty_suggestion : prev.difficulty,
question_content: (mergeQuestionContent(data.question_content, data.text) || prev.question_content || "").slice(0, 8000), question_content: (mergeQuestionContent(data.question_content, data.text) || prev.question_content || "").slice(0, 8000),
@@ -885,6 +921,60 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
} }
}; };
const batchRecognizePending = async () => {
setBatchRecognizing(true);
try {
let candidates = [];
if (batchRecognizeScope === "current_filtered") {
candidates = (items || []).filter((item) => isPendingImageTitle(item.title) && String(item.image_url || "").trim());
} else {
const res = await api.get("/api/mistakes", {
params: {
keyword: PENDING_IMAGE_TITLE,
sort_by: "created_at",
order: "desc"
}
});
candidates = (res.data || []).filter((item) => isPendingImageTitle(item.title) && String(item.image_url || "").trim());
}
if (!candidates.length) {
show(batchRecognizeScope === "current_filtered" ? "当前筛选结果没有可批量识别的待补录图片错题" : "没有可批量识别的待补录图片错题");
return;
}
let ok = 0;
let fail = 0;
for (const item of candidates) {
try {
const parsed = await api.post("/api/ocr/parse", { image_url: item.image_url });
const data = parsed.data;
await api.put(`/api/mistakes/${item.id}`, {
...item,
title: resolveTitleAfterOcr(item.title, data),
category: MISTAKE_CATEGORIES.includes(data.category_suggestion) ? data.category_suggestion : item.category,
difficulty: ["easy", "medium", "hard"].includes(data.difficulty_suggestion) ? data.difficulty_suggestion : item.difficulty || "medium",
question_content: (mergeQuestionContent(data.question_content, data.text) || item.question_content || "").slice(0, 8000),
answer: (data.answer || item.answer || "").slice(0, 4000),
explanation: (data.explanation || item.explanation || "").slice(0, 8000),
note: [item.note, data.text].filter(Boolean).join("\n\n").slice(0, 4000),
wrong_count: Number(item.wrong_count || 1)
});
ok += 1;
} catch {
fail += 1;
}
}
await load();
show(`批量识别完成:成功 ${ok},失败 ${fail}`);
} catch (error) {
show(getApiErrorMessage(error, "批量识别失败"));
} finally {
setBatchRecognizing(false);
}
};
const submit = async (e) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!form.category) return show("请选择分类"); if (!form.category) return show("请选择分类");
@@ -897,7 +987,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
title: title:
form.title || form.title ||
buildTitleFromQuestion(form.question_content) || buildTitleFromQuestion(form.question_content) ||
(hasImage ? "待补录图片错题" : "") || (hasImage ? PENDING_IMAGE_TITLE : "") ||
`错题-${Date.now()}` `错题-${Date.now()}`
}; };
try { try {
@@ -937,7 +1027,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
title: title:
editingItem.title || editingItem.title ||
buildTitleFromQuestion(editingItem.question_content) || buildTitleFromQuestion(editingItem.question_content) ||
(hasImage ? "待补录图片错题" : "") || (hasImage ? PENDING_IMAGE_TITLE : "") ||
`错题-${editingItem.id}`, `错题-${editingItem.id}`,
wrong_count: Number(editingItem.wrong_count || 1) wrong_count: Number(editingItem.wrong_count || 1)
}); });
@@ -1042,7 +1132,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
try { try {
const url = await uploadImageFileOnly(file); const url = await uploadImageFileOnly(file);
await api.post("/api/mistakes", { await api.post("/api/mistakes", {
title: "待补录图片错题", title: PENDING_IMAGE_TITLE,
image_url: url, image_url: url,
category: "其他", category: "其他",
difficulty: "medium", difficulty: "medium",
@@ -1094,6 +1184,18 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
> >
<FileDown size={18} /> 导出错题 <FileDown size={18} /> 导出错题
</button> </button>
<select
className="select-min"
value={batchRecognizeScope}
onChange={(e) => setBatchRecognizeScope(e.target.value)}
aria-label="批量识别范围"
>
<option value="all_pending">识别范围全部待补录</option>
<option value="current_filtered">识别范围当前筛选</option>
</select>
<button type="button" className="btn btn-outline btn-pill" onClick={batchRecognizePending} disabled={batchRecognizing}>
{batchRecognizing ? "批量识别中…" : "批量识别待补录"}
</button>
</div> </div>
<div className="toolbar-right"> <div className="toolbar-right">
<select className="select-min" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} aria-label="分类"> <select className="select-min" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)} aria-label="分类">
@@ -1846,6 +1948,7 @@ export default function App() {
const [mainTab, setMainTab] = useState("mistake"); const [mainTab, setMainTab] = useState("mistake");
const [extraTab, setExtraTab] = useState("resource"); const [extraTab, setExtraTab] = useState("resource");
const [showQuickCamera, setShowQuickCamera] = useState(false); const [showQuickCamera, setShowQuickCamera] = useState(false);
const [quickCameraFabCompact, setQuickCameraFabCompact] = useState(() => readQuickCameraFabCompact());
const [quickCaptureMode, setQuickCaptureMode] = useState("single"); const [quickCaptureMode, setQuickCaptureMode] = useState("single");
const [quickCaptureTask, setQuickCaptureTask] = useState(null); const [quickCaptureTask, setQuickCaptureTask] = useState(null);
const quickCaptureInputRef = useRef(null); const quickCaptureInputRef = useRef(null);
@@ -1854,6 +1957,11 @@ export default function App() {
quickCaptureInputRef.current?.click(); quickCaptureInputRef.current?.click();
}; };
const setQuickCameraFabCompactPersist = (value) => {
setQuickCameraFabCompact(value);
writeQuickCameraFabCompact(value);
};
const onQuickCapturePicked = (filesLike) => { const onQuickCapturePicked = (filesLike) => {
const files = Array.from(filesLike || []); const files = Array.from(filesLike || []);
if (!files.length) return; if (!files.length) return;
@@ -1963,10 +2071,46 @@ export default function App() {
onChange={(e) => onQuickCapturePicked(e.target.files)} onChange={(e) => onQuickCapturePicked(e.target.files)}
/> />
<button type="button" className="quick-camera-fab" onClick={() => setShowQuickCamera(true)} aria-label="快速拍照录题"> <div className="quick-camera-fab-shell">
<Camera size={20} /> {quickCameraFabCompact ? (
快速拍题 <div className="quick-camera-fab-cluster">
<button
type="button"
className="quick-camera-fab-expand"
onClick={() => setQuickCameraFabCompactPersist(false)}
aria-label="展开快速拍题标签"
title="展开标签"
>
<Maximize2 size={13} strokeWidth={2.25} aria-hidden />
</button> </button>
<button
type="button"
className="quick-camera-fab quick-camera-fab--compact"
onClick={() => setShowQuickCamera(true)}
aria-label="快速拍照录题(已收纳为图标)"
title="快速拍题"
>
<Camera size={20} strokeWidth={2} aria-hidden />
</button>
</div>
) : (
<div className="quick-camera-fab quick-camera-fab--split" role="group" aria-label="快速拍题">
<button type="button" className="quick-camera-fab__open" onClick={() => setShowQuickCamera(true)}>
<Camera size={20} strokeWidth={2} aria-hidden />
<span>快速拍题</span>
</button>
<button
type="button"
className="quick-camera-fab__collapse"
onClick={() => setQuickCameraFabCompactPersist(true)}
aria-label="收纳为图标,减少遮挡"
title="收纳为图标"
>
<Minimize2 size={14} strokeWidth={2.25} aria-hidden />
</button>
</div>
)}
</div>
{showQuickCamera && ( {showQuickCamera && (
<Modal title="快速拍照录题" onClose={() => setShowQuickCamera(false)}> <Modal title="快速拍照录题" onClose={() => setShowQuickCamera(false)}>

View File

@@ -846,14 +846,18 @@ textarea {
width: auto; width: auto;
} }
.quick-camera-fab { .quick-camera-fab-shell {
position: fixed; position: fixed;
right: 18px; right: 18px;
bottom: calc(18px + env(safe-area-inset-bottom)); bottom: calc(18px + env(safe-area-inset-bottom));
z-index: 90; z-index: 90;
display: inline-flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
gap: 8px; gap: 0;
}
.quick-camera-fab {
border: none; border: none;
border-radius: 999px; border-radius: 999px;
padding: 12px 16px; padding: 12px 16px;
@@ -864,11 +868,125 @@ textarea {
cursor: pointer; cursor: pointer;
} }
.quick-camera-fab--split {
display: inline-flex;
align-items: stretch;
padding: 0;
overflow: hidden;
}
.quick-camera-fab__open {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 4px 12px 16px;
border: none;
background: transparent;
color: inherit;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.quick-camera-fab__collapse {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 14px 12px 10px;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(0, 0, 0, 0.06);
color: inherit;
cursor: pointer;
}
.quick-camera-fab__collapse:hover,
.quick-camera-fab__collapse:focus-visible {
background: rgba(0, 0, 0, 0.12);
}
.quick-camera-fab__open:focus-visible,
.quick-camera-fab__collapse:focus-visible,
.quick-camera-fab--compact:focus-visible,
.quick-camera-fab-expand:focus-visible {
outline: 2px solid #93c5fd;
outline-offset: 2px;
}
/* 收纳态:展开键 + 相机融为一条胶囊,单阴影 */
.quick-camera-fab-cluster {
display: inline-flex;
align-items: stretch;
height: 42px;
border-radius: 21px;
overflow: hidden;
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.26);
background: linear-gradient(135deg, #0ea5e9, #2563eb);
}
.quick-camera-fab-cluster .quick-camera-fab-expand {
flex: 0 0 24px;
width: 24px;
min-width: 24px;
padding: 0;
border: none;
border-right: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0;
background: rgba(0, 0, 0, 0.07);
color: rgba(255, 255, 255, 0.92);
box-shadow: none;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.quick-camera-fab-cluster .quick-camera-fab-expand:hover,
.quick-camera-fab-cluster .quick-camera-fab-expand:focus-visible {
background: rgba(0, 0, 0, 0.12);
}
.quick-camera-fab-cluster .quick-camera-fab--compact {
width: 44px;
min-width: 44px;
height: auto;
padding: 0;
border-radius: 0;
box-shadow: none;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
}
@media (max-width: 860px) { @media (max-width: 860px) {
.quick-camera-fab { .quick-camera-fab-shell {
right: 14px; right: 14px;
bottom: calc(76px + env(safe-area-inset-bottom)); bottom: calc(76px + env(safe-area-inset-bottom));
padding: 11px 14px; }
.quick-camera-fab__open {
padding: 11px 4px 11px 14px;
}
.quick-camera-fab__collapse {
padding: 11px 12px 11px 8px;
}
.quick-camera-fab-cluster {
height: 40px;
border-radius: 20px;
}
.quick-camera-fab-cluster .quick-camera-fab-expand {
flex-basis: 22px;
width: 22px;
min-width: 22px;
}
.quick-camera-fab-cluster .quick-camera-fab--compact {
width: 42px;
min-width: 42px;
} }
} }