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" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>学习伙伴</title>
<script type="module" crossorigin src="/assets/index-9iYNjrsg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-dSv0ph.css">
<script type="module" crossorigin src="/assets/index-Dw7fpRnj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CoOKrmS9.css">
</head>
<body>
<div id="root"></div>

View File

@@ -9,6 +9,8 @@ import {
FileDown,
FolderOpen,
GraduationCap,
Maximize2,
Minimize2,
MoreHorizontal,
NotebookPen,
Plus,
@@ -17,6 +19,24 @@ import {
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 MISTAKE_CATEGORIES = ["常识", "言语", "数量", "判断", "资料", "科学", "其他"];
@@ -616,6 +636,7 @@ function ResourceModule() {
}
function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const PENDING_IMAGE_TITLE = "待补录图片错题";
const [items, setItems] = useState([]);
const [selectedExportIds, setSelectedExportIds] = useState([]);
const [categoryFilter, setCategoryFilter] = useState("");
@@ -632,6 +653,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
const [exportScope, setExportScope] = useState("all");
const [exportContentMode, setExportContentMode] = useState("full");
const [exportRange, setExportRange] = useState({ start_date: "", end_date: "" });
const [batchRecognizeScope, setBatchRecognizeScope] = useState("all_pending");
const uploadInputRef = useRef(null);
const captureInputRef = useRef(null);
@@ -651,6 +673,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
});
const [ocrText, setOcrText] = useState("");
const [ocrLoading, setOcrLoading] = useState(false);
const [batchRecognizing, setBatchRecognizing] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [detailItem, setDetailItem] = useState(null);
@@ -660,6 +683,15 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
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 只有短问句;题目内容应取更完整的一份 */
const mergeQuestionContent = (questionContent, fullText) => {
const q = String(questionContent || "").trim();
@@ -689,7 +721,11 @@ 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)));
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) {
show(getApiErrorMessage(error, "加载错题失败"));
}
@@ -708,7 +744,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
setOcrText(data.text || "");
setForm((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,
difficulty: ["easy", "medium", "hard"].includes(data.difficulty_suggestion) ? data.difficulty_suggestion : prev.difficulty,
question_content: (
@@ -868,7 +904,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
if (!prev) return prev;
return {
...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,
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),
@@ -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) => {
e.preventDefault();
if (!form.category) return show("请选择分类");
@@ -897,7 +987,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
title:
form.title ||
buildTitleFromQuestion(form.question_content) ||
(hasImage ? "待补录图片错题" : "") ||
(hasImage ? PENDING_IMAGE_TITLE : "") ||
`错题-${Date.now()}`
};
try {
@@ -937,7 +1027,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
title:
editingItem.title ||
buildTitleFromQuestion(editingItem.question_content) ||
(hasImage ? "待补录图片错题" : "") ||
(hasImage ? PENDING_IMAGE_TITLE : "") ||
`错题-${editingItem.id}`,
wrong_count: Number(editingItem.wrong_count || 1)
});
@@ -1042,7 +1132,7 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
try {
const url = await uploadImageFileOnly(file);
await api.post("/api/mistakes", {
title: "待补录图片错题",
title: PENDING_IMAGE_TITLE,
image_url: url,
category: "其他",
difficulty: "medium",
@@ -1094,6 +1184,18 @@ function MistakeModule({ quickCaptureTask, onQuickCaptureHandled }) {
>
<FileDown size={18} /> 导出错题
</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 className="toolbar-right">
<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 [extraTab, setExtraTab] = useState("resource");
const [showQuickCamera, setShowQuickCamera] = useState(false);
const [quickCameraFabCompact, setQuickCameraFabCompact] = useState(() => readQuickCameraFabCompact());
const [quickCaptureMode, setQuickCaptureMode] = useState("single");
const [quickCaptureTask, setQuickCaptureTask] = useState(null);
const quickCaptureInputRef = useRef(null);
@@ -1854,6 +1957,11 @@ export default function App() {
quickCaptureInputRef.current?.click();
};
const setQuickCameraFabCompactPersist = (value) => {
setQuickCameraFabCompact(value);
writeQuickCameraFabCompact(value);
};
const onQuickCapturePicked = (filesLike) => {
const files = Array.from(filesLike || []);
if (!files.length) return;
@@ -1963,10 +2071,46 @@ export default function App() {
onChange={(e) => onQuickCapturePicked(e.target.files)}
/>
<button type="button" className="quick-camera-fab" onClick={() => setShowQuickCamera(true)} aria-label="快速拍照录题">
<Camera size={20} />
快速拍题
<div className="quick-camera-fab-shell">
{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
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 && (
<Modal title="快速拍照录题" onClose={() => setShowQuickCamera(false)}>

View File

@@ -846,14 +846,18 @@ textarea {
width: auto;
}
.quick-camera-fab {
.quick-camera-fab-shell {
position: fixed;
right: 18px;
bottom: calc(18px + env(safe-area-inset-bottom));
z-index: 90;
display: inline-flex;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
gap: 0;
}
.quick-camera-fab {
border: none;
border-radius: 999px;
padding: 12px 16px;
@@ -864,11 +868,125 @@ textarea {
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) {
.quick-camera-fab {
.quick-camera-fab-shell {
right: 14px;
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;
}
}