Skip to content

Commit 1164136

Browse files
margropclaude
andcommitted
feat: 录入方式、裁剪模式与组卷偏好记住上次选择
首页与错题本添加页的录入方式、拍照裁剪的单题裁剪/自动分题/手工框选、组卷表单的模式、范围、题量、难度和抽题规则, 保存在本机浏览器中,下次打开直接使用。当前页面或裁剪框不提供保存的选项时回退默认且不覆盖保存值; 无法写入本机存储时只在本次生效;服务端渲染输出默认值,避免水合不一致。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 963df9b commit 1164136

12 files changed

Lines changed: 276 additions & 23 deletions

File tree

‎doc/wiki/Practice-and-Exams.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
4. **原题直出**:直接使用原题的题干、答案、解析和配图组卷,不调用 AI、不消耗 AI 额度,创建后立即可打印。无需选择难度。
4646
5. 打印或导出,完成后按题回填作答结果。
4747

48+
组卷表单会在本机浏览器中记住上次的模式、掌握范围、题量、难度和抽题规则;错题本、知识点、题型和标题每次重新选择。
49+
4850
创建试卷时打开「同时生成 A/B 卷」,系统会按同样条件一次抽取最多 2×题量的互不重复题目,按题型与难度均衡分配为两份平行试卷:A 卷与 B 卷题目互不重复,题型分布与难度尽量接近,适合课堂测验或对比练习。标题自动加「(A 卷)」「(B 卷)」后缀;举一反三模式下两卷分别生成、各自消耗一次 AI 额度。源题不足两份时,若 B 卷至少能分到 1 题仍会创建两卷(B 卷题数可少于 A 卷)并提示题目不足;连 B 卷 1 题都没有时只创建 A 卷。
4951

5052
原题直出模式的试卷不显示"换一道题"按钮(原题不可更换),但仍可"重新生成解答";录入结果时难度记为空。

‎doc/wiki/User-Guide.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
| 导入题库文件 | 已有的题库文件(JSON / Word / Excel / CSV / PDF),解析后按批次导入 |
2222
| 直接录入 | 手工整理已知的题干、答案和解析 |
2323

24+
录入页和裁剪窗口会记住你上次选的录入方式(拍照、手动输入、导入题库文件、直接录入)和裁剪模式(单题裁剪、自动分题、手工框选),下次打开时直接使用。选择保存在本机浏览器中,换设备或清除网站数据后回到默认的拍照和单题裁剪;错题本内的添加页只有拍照和手动输入,上次用的是其他方式时显示拍照。
25+
2426
手机上有三个独立入口:「拍照」直接打开相机(设备没有摄像头时会提示改用相册或文件);「相册」从系统照片中选择;「从文件选择」不限文件类型,适用于相册中看不到的照片(部分系统不把 HEIC/HEIF 识别为图片)。选择后按文件内容识别真实格式:HEIC/HEIF 照片在裁剪前转换为 JPEG 并按拍摄方向摆正,浏览器能直接解码时不额外下载解码组件;文件损坏或不是图片时会提示具体原因。
2527

2628
图片可以先裁剪,裁剪和多题框选时可用鼠标滚轮、触屏双指捏合或右上角的按钮缩放(100%~500%,点击比例还原);一张图片上有多道题时,可选择「自动分题」由 AI 识别每道题(见下文「多题录入」),或「手工框选」多题批量导入。一道题分在两张(或更多)纸上时,在「单题裁剪」中框选第一段后点「下一段在另一张」,这一段会暂存在录入页上方;再拍摄或选择下一张照片,框选接着的部分,点「拼成一题并识别」,各段按顺序缩放到同一宽度、上下拼成一张图后识别。一道题最多 4 段,已暂存时也可直接点「拼成一题并识别」或「放弃已暂存的」;暂存期间裁剪只提供单题模式。裁剪预览使用缩略图,确认后从原图裁剪,输出按最长边 4096 像素、约 490 万像素和 1.5MB 共同限制,JPEG 质量不低于 0.8,以保留小字、公式和配图。尽量保留完整题干、必要图形和学生作答,避免模糊或裁掉条件。

‎doc/wiki/en/Practice-and-Exams-en.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ The **Review** page in the navigation lists questions due today, and the home pa
4545
4. **Original**: uses the original question text, answer, analysis and figure directly. No AI is called and no AI quota is consumed; the paper is ready to print immediately. No difficulty selection needed.
4646
5. Print or export it, and afterwards record the result for each question.
4747

48+
The exam form remembers the mode, mastery scope, question count, difficulty and pick rule you used last time in this browser; notebooks, knowledge points, question types and the title are chosen again each time.
49+
4850
When creating an exam, turn on **Generate A/B papers simultaneously**. The system picks up to 2× the question count of non-overlapping questions under the same conditions and splits them into two parallel papers, balanced by question type and difficulty: Paper A and Paper B have no overlapping questions, with similar type distributions and difficulty levels—suitable for class quizzes or comparison practice. Titles automatically get the suffixes "(Paper A)" and "(Paper B)". In Similar Questions mode, each paper generates separately and consumes its own AI quota. When there are not enough source questions, Paper B is still created as long as it gets at least one question (it may have fewer questions than Paper A) and a warning is shown; if Paper B would get zero questions, only Paper A is created.
4951

5052
Original-mode papers do not show the "Regenerate question" button (the original question cannot be replaced), but "Regenerate solution" is still available; recorded results store no difficulty.

‎doc/wiki/en/User-Guide-en.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ The notebook list shows only each name and question count; click one to open its
2121
| Import question file | Existing question bank files (JSON / Word / Excel / CSV / PDF), parsed and imported in batches |
2222
| Direct entry | Manually record a known question, answer and analysis |
2323

24+
The add page and the crop window remember the input method (photo, AI solve, import question file, direct entry) and crop mode (single question, auto split, draw boxes) you used last time. The choice is saved in this browser; on another device or after clearing site data, photo and single question are used again. The add page inside a notebook only offers photo and AI solve, so it shows photo if another method was used last time.
25+
2426
Images can be cropped first. While cropping or selecting several questions, zoom with the mouse wheel, a two-finger pinch or the buttons at the top right (100%–500%; click the percentage to reset). When one image contains several questions, choose **Auto split** to let AI find each question (see "Multiple questions" below), or **Draw boxes** to select them for a batch import. Keep the full question, any required figure and the student's work, and avoid blurry images or cut-off conditions.
2527

2628
On phones there are three separate entries: **Take photo** opens the camera directly (if the device has no camera you are asked to use the album or a file instead); **Photo album** picks from your photos; **Choose file** accepts any file type, for photos that do not show up in the album (some systems do not treat HEIC/HEIF as images). After selection the real format is detected from the file content: HEIC/HEIF photos are converted to JPEG and rotated upright before cropping, and no extra decoder is downloaded when the browser can decode them itself. Damaged files or files that are not images show a specific reason.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* 记住上次选择:录入方式、裁剪模式等保存在本机浏览器,不合法的值回退默认;
3+
* 服务端渲染输出默认值,避免水合不一致;组卷偏好逐项校验
4+
*/
5+
import { act } from 'react';
6+
import { createRoot } from 'react-dom/client';
7+
import type { Root } from 'react-dom/client';
8+
import { renderToString } from 'react-dom/server';
9+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10+
import { useStoredChoice } from '@/lib/hooks/use-stored-choice';
11+
import { DEFAULT_EXAM_FORM_PREFERENCES, parseExamFormPreferences, serializeExamFormPreferences } from '@/lib/exam/exam-form-preferences';
12+
import { HOME_INPUT_MODES, INPUT_MODE_STORAGE_KEY, NOTEBOOK_INPUT_MODES } from '@/lib/ui-preferences';
13+
14+
function Probe({ options, next = 'text' }: { options: readonly string[]; next?: string }) {
15+
const [value, set] = useStoredChoice(INPUT_MODE_STORAGE_KEY, options, 'image');
16+
return <button type="button" onClick={() => set(next)}>{value}</button>;
17+
}
18+
19+
const choose = (container: HTMLElement) => act(() => container.querySelector('button')?.click());
20+
21+
describe('useStoredChoice', () => {
22+
let container: HTMLDivElement;
23+
let root: Root;
24+
25+
beforeEach(() => {
26+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
27+
localStorage.clear();
28+
container = document.createElement('div');
29+
root = createRoot(container);
30+
});
31+
32+
afterEach(() => {
33+
act(() => root.unmount());
34+
vi.unstubAllGlobals();
35+
});
36+
37+
it('没有保存时用默认值,选择后写入本机并更新', () => {
38+
act(() => root.render(<Probe options={HOME_INPUT_MODES} />));
39+
expect(container.textContent).toBe('image');
40+
choose(container);
41+
expect(container.textContent).toBe('text');
42+
expect(localStorage.getItem(INPUT_MODE_STORAGE_KEY)).toBe('text');
43+
});
44+
45+
it('重新打开时读取上次的选择', () => {
46+
localStorage.setItem(INPUT_MODE_STORAGE_KEY, 'file');
47+
act(() => root.render(<Probe options={HOME_INPUT_MODES} />));
48+
expect(container.textContent).toBe('file');
49+
});
50+
51+
it('保存的值不在当前可选范围时回退默认,且不覆盖保存的值', () => {
52+
localStorage.setItem(INPUT_MODE_STORAGE_KEY, 'file');
53+
act(() => root.render(<Probe options={NOTEBOOK_INPUT_MODES} />));
54+
expect(container.textContent).toBe('image');
55+
expect(localStorage.getItem(INPUT_MODE_STORAGE_KEY)).toBe('file');
56+
});
57+
58+
it('无法写入本机存储时只在本次生效', () => {
59+
act(() => root.render(<Probe options={HOME_INPUT_MODES} next="direct" />));
60+
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
61+
throw new Error('QuotaExceededError');
62+
});
63+
choose(container);
64+
expect(container.textContent).toBe('direct');
65+
setItem.mockRestore();
66+
});
67+
68+
it('服务端渲染输出默认值', () => {
69+
localStorage.setItem(INPUT_MODE_STORAGE_KEY, 'text');
70+
expect(renderToString(<Probe options={HOME_INPUT_MODES} />)).toContain('image');
71+
});
72+
});
73+
74+
describe('parseExamFormPreferences', () => {
75+
it('没有保存或内容损坏时用默认值', () => {
76+
expect(parseExamFormPreferences(null)).toEqual(DEFAULT_EXAM_FORM_PREFERENCES);
77+
expect(parseExamFormPreferences('{oops')).toEqual(DEFAULT_EXAM_FORM_PREFERENCES);
78+
expect(parseExamFormPreferences('"text"')).toEqual(DEFAULT_EXAM_FORM_PREFERENCES);
79+
});
80+
81+
it('保存后能完整还原', () => {
82+
const preferences = { mode: 'original', scope: 'all', pickRule: 'random', count: 25, difficulty: 'harder' } as const;
83+
expect(parseExamFormPreferences(serializeExamFormPreferences(preferences))).toEqual(preferences);
84+
});
85+
86+
it('不合法的字段逐项回退,题数限制在 1–50', () => {
87+
const parsed = parseExamFormPreferences(JSON.stringify({ mode: 'magic', scope: 'mastered', count: 999, difficulty: 3 }));
88+
expect(parsed).toEqual({ ...DEFAULT_EXAM_FORM_PREFERENCES, scope: 'mastered', count: 50 });
89+
expect(parseExamFormPreferences(JSON.stringify({ count: -3 })).count).toBe(1);
90+
expect(parseExamFormPreferences(JSON.stringify({ count: 'NaN' })).count).toBe(DEFAULT_EXAM_FORM_PREFERENCES.count);
91+
});
92+
});

‎src/app/notebooks/[id]/add/page.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { apiClient } from "@/lib/api-client";
2020
import { frontendLogger } from "@/lib/frontend-logger";
2121
import { usePageTitle } from "@/lib/page-title";
2222
import { MAX_QUESTION_PARTS, useQuestionImport } from "@/lib/hooks/use-question-import";
23+
import { useStoredChoice } from "@/lib/hooks/use-stored-choice";
24+
import { INPUT_MODE_STORAGE_KEY, NOTEBOOK_INPUT_MODES } from "@/lib/ui-preferences";
25+
import type { NotebookInputMode } from "@/lib/ui-preferences";
2326
import type { Notebook } from "@/types/api";
2427

2528
export default function AddErrorPage() {
@@ -29,8 +32,8 @@ export default function AddErrorPage() {
2932
const { t } = useLanguage();
3033
const [notebook, setNotebook] = useState<Notebook | null>(null);
3134
usePageTitle(t.notebooks.title, notebook?.name, t.pageTitle.addError);
32-
// Input mode: "image" for photo upload, "text" for manual text input
33-
const [inputMode, setInputMode] = useState<"image" | "text">("image");
35+
// 录入方式:image 拍照,text 文字录入;记住上次的选择
36+
const [inputMode, setInputMode] = useStoredChoice<NotebookInputMode>(INPUT_MODE_STORAGE_KEY, NOTEBOOK_INPUT_MODES, "image");
3437

3538
// 本页的题目都放入当前错题本
3639
const flow = useQuestionImport({

‎src/app/page.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import { apiClient } from "@/lib/api-client";
2323
import { frontendLogger } from "@/lib/frontend-logger";
2424
import { usePageTitle } from "@/lib/page-title";
2525
import { MAX_QUESTION_PARTS, useQuestionImport } from "@/lib/hooks/use-question-import";
26+
import { useStoredChoice } from "@/lib/hooks/use-stored-choice";
27+
import { HOME_INPUT_MODES, INPUT_MODE_STORAGE_KEY } from "@/lib/ui-preferences";
28+
import type { HomeInputMode } from "@/lib/ui-preferences";
2629
import type { Notebook, PaginatedResponse } from "@/types/api";
2730

2831
/** 直接录入(不经 AI)提交的内容 */
@@ -47,7 +50,8 @@ function HomeContent() {
4750
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
4851
const [dueCount, setDueCount] = useState<number | null>(null);
4952
// Input mode: "image" for photo upload, "text" for AI solve, "direct" for manual entry, "file" for question bank files
50-
const [inputMode, setInputMode] = useState<"image" | "text" | "direct" | "file">("image");
53+
// 记住上次的录入方式
54+
const [inputMode, setInputMode] = useStoredChoice<HomeInputMode>(INPUT_MODE_STORAGE_KEY, HOME_INPUT_MODES, "image");
5155

5256
const targetNotebookId = initialNotebookId;
5357
const targetNotebook = targetNotebookId ? notebooks.find(n => n.id === targetNotebookId) : undefined;

‎src/components/exam/exam-create-form.tsx‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,23 @@ import { TagInput } from "@/components/tag-input";
1313
import { ExamModeToggle } from "@/components/exam/exam-mode-toggle";
1414
import { useLanguage } from "@/contexts/LanguageContext";
1515
import { apiClient } from "@/lib/api-client";
16+
import {
17+
clampExamCount,
18+
EXAM_FORM_DIFFICULTIES as DIFFICULTIES,
19+
EXAM_FORM_MAX_COUNT,
20+
EXAM_FORM_PICK_RULES as PICK_RULES,
21+
EXAM_FORM_PREFERENCES_STORAGE_KEY,
22+
EXAM_FORM_SCOPES as SCOPES,
23+
parseExamFormPreferences,
24+
serializeExamFormPreferences,
25+
} from "@/lib/exam/exam-form-preferences";
1626
import { usePaperGroups } from "@/lib/hooks/use-paper-groups";
27+
import { useStoredValue } from "@/lib/hooks/use-stored-choice";
1728
import { QUESTION_TYPES, NO_QUESTION_TYPE } from "@/lib/question/question-type";
1829
import type { QuestionType } from "@/lib/question/question-type";
19-
import type { ExamMode, ExamPickRule, ExamScope, Notebook } from "@/types/api";
30+
import type { ExamFormDifficulty, ExamFormPreferences } from "@/lib/exam/exam-form-preferences";
31+
import type { Notebook } from "@/types/api";
2032

21-
const SCOPES: ExamScope[] = ["unmastered", "mastered", "all"];
22-
const PICK_RULES: ExamPickRule[] = ["least_practiced", "random"];
23-
const DIFFICULTIES = ["easy", "medium", "hard", "harder"] as const;
2433

2534
interface CreateExamResponse {
2635
id: string;
@@ -41,11 +50,10 @@ export function ExamCreateForm() {
4150
const [tagNames, setTagNames] = useState<string[]>([]);
4251
const [paperLevelIds, setPaperLevelIds] = useState<string[]>([]);
4352
const [questionTypes, setQuestionTypes] = useState<string[]>([]);
44-
const [scope, setScope] = useState<ExamScope>("unmastered");
45-
const [pickRule, setPickRule] = useState<ExamPickRule>("least_practiced");
46-
const [count, setCount] = useState(10);
47-
const [difficulty, setDifficulty] = useState<typeof DIFFICULTIES[number]>("medium");
48-
const [mode, setMode] = useState<ExamMode>("generate");
53+
// 出题方式、范围、题数、难度、抽题规则记住上次的选择
54+
const [preferences, savePreferences] = useStoredValue(EXAM_FORM_PREFERENCES_STORAGE_KEY, parseExamFormPreferences, serializeExamFormPreferences);
55+
const { scope, pickRule, count, difficulty, mode } = preferences;
56+
const updatePreferences = (patch: Partial<ExamFormPreferences>) => savePreferences({ ...preferences, ...patch });
4957
const [title, setTitle] = useState("");
5058
const [parallel, setParallel] = useState(false);
5159
const [submitting, setSubmitting] = useState(false);
@@ -210,7 +218,7 @@ export function ExamCreateForm() {
210218
<p className="text-xs text-muted-foreground">{t.exam.questionTypesHint}</p>
211219
</div>
212220

213-
<ExamModeToggle value={mode} onChange={setMode} />
221+
<ExamModeToggle value={mode} onChange={(value) => updatePreferences({ mode: value })} />
214222

215223
<div className={`grid gap-4 ${mode === "generate" ? "sm:grid-cols-3" : "sm:grid-cols-2"}`}>
216224
<div className="space-y-2">
@@ -220,7 +228,7 @@ export function ExamCreateForm() {
220228
<button
221229
key={value}
222230
type="button"
223-
onClick={() => setScope(value)}
231+
onClick={() => updatePreferences({ scope: value })}
224232
className={`rounded px-2 py-1.5 text-sm transition-colors ${scope === value ? "bg-background shadow-sm font-medium" : "text-muted-foreground"}`}
225233
>
226234
{t.exam.scopes[value]}
@@ -234,9 +242,9 @@ export function ExamCreateForm() {
234242
id="exam-count"
235243
type="number"
236244
min={1}
237-
max={50}
245+
max={EXAM_FORM_MAX_COUNT}
238246
value={count}
239-
onChange={(e) => setCount(Math.min(50, Math.max(1, parseInt(e.target.value, 10) || 1)))}
247+
onChange={(e) => updatePreferences({ count: clampExamCount(parseInt(e.target.value, 10) || 1) })}
240248
/>
241249
</div>
242250
{mode === "generate" && (
@@ -245,7 +253,7 @@ export function ExamCreateForm() {
245253
<select
246254
id="exam-difficulty"
247255
value={difficulty}
248-
onChange={(e) => setDifficulty(e.target.value as typeof DIFFICULTIES[number])}
256+
onChange={(e) => updatePreferences({ difficulty: e.target.value as ExamFormDifficulty })}
249257
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
250258
>
251259
{DIFFICULTIES.map((value) => (
@@ -263,7 +271,7 @@ export function ExamCreateForm() {
263271
<button
264272
key={value}
265273
type="button"
266-
onClick={() => setPickRule(value)}
274+
onClick={() => updatePreferences({ pickRule: value })}
267275
className={`rounded px-2 py-1.5 text-sm transition-colors ${pickRule === value ? "bg-background shadow-sm font-medium" : "text-muted-foreground"}`}
268276
>
269277
{t.exam.pickRules[value]}

0 commit comments

Comments
 (0)