Skip to content

Commit 55beed9

Browse files
committed
fix: 开放题目年级学期编辑并保留手工修改
1 parent 5dbc42c commit 55beed9

13 files changed

Lines changed: 173 additions & 16 deletions

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ General settings control review before import, SVG animated explanations, GeoGeb
6868
<details>
6969
<summary><strong>Question details: formulas, difficulty stars, knowledge points and paper info</strong></summary>
7070

71-
Each question shows its text, knowledge points, grade, term and paper level. You can check the original photo and start practicing similar questions from here.
71+
Each question shows its text, knowledge points, grade, term and paper level. Click **Edit grade/semester** to change the grade or term; the review page also provides a selector. You can check the original photo and start practicing similar questions from here.
7272

7373
![An English linear-equation question with formulas, difficulty stars, knowledge points, grade and term](doc/screenshots/en/question-detail.png)
7474

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
<details>
6969
<summary><strong>题目详情:公式排版、难度星级、知识点和试题信息</strong></summary>
7070

71-
一道错题集中展示题干、知识点、年级学期与试卷等级,可核对原题图片,并从详情页进入举一反三。
71+
一道错题集中展示题干、知识点、年级学期与试卷等级;点击「编辑年级/学期」可调整归属,校对页也可手工选择,可核对原题图片,并从详情页进入举一反三。
7272

7373
![真实错题详情中的公式、难度星级、知识点和年级学期信息](doc/screenshots/question-detail.png)
7474

‎doc/wiki/User-Guide.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
| --- | --- |
4040
| 6 位题目 ID | 可复制,在错题本按 ID 定位;打印中显示为来源 ID |
4141
| 1~5 星难度 | AI 参考竞赛数学难度标准评定,可手动修改 |
42+
| 年级/学期 | 点击「编辑年级/学期」选择并保存;校对页也可修改,加载账户资料不会覆盖手工选择 |
4243
| A/B 卷 | 未指定时,1~3 星默认 A 卷,4~5 星默认 B 卷;已有手动选择优先 |
4344
| 知识点 | 使用对应学段与学科的标签,支持单题或整本重新识别 |
4445
| 重新生成完整解答 | 生成新的答案和解析,覆盖前会要求确认 |

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Each question shows its text, answer, analysis, knowledge points, difficulty, se
3939
| --- | --- |
4040
| 6-character question ID | Copy it and locate the question by ID in a notebook; shown as the source ID in printouts |
4141
| 1–5 star difficulty | Rated by the AI with reference to competition-math difficulty; can be changed manually |
42+
| Grade/semester | Click **Edit grade/semester** to change and save it; review also offers a selector, and loading the profile preserves manual edits |
4243
| Paper A/B | When not set, 1–3 stars default to paper A and 4–5 stars to paper B; a manual choice takes priority |
4344
| Knowledge points | Tags from the matching stage and subject; re-recognize one question or a whole notebook |
4445
| Regenerate full solution | Generates a new answer and analysis; asks for confirmation before overwriting |

‎e2e/error-detail.spec.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { test, expect, type Page } from '@playwright/test';
22
import { ADMIN_PASSWORD } from './helpers';
33

44
async function login(page: Page) {
5+
const hydrated = page.waitForResponse((response) => response.url().endsWith('/api/register/status'));
56
await page.goto('/login');
7+
await hydrated;
68
await page.getByLabel(/邮箱|Email/).fill('admin@localhost');
79
await page.getByLabel(/^密码$|^Password$/).fill(ADMIN_PASSWORD);
810
await page.getByRole('button', { name: /登录|Login/ }).click();
@@ -45,11 +47,18 @@ test('错题详情页显示各区块,编辑后保存并在刷新后保留', as
4547
await expect(card(page, /^解析$/)).toContainText('路程等于速度乘以时间。');
4648
await expect(card(page, '你的笔记 / 答案')).toContainText('暂无笔记');
4749

50+
// 年级有明确编辑入口,跨学段修改后持久保存。
51+
await page.getByRole('button', { name: '编辑年级/学期' }).click();
52+
await page.getByRole('combobox', { name: '年级/学期' }).selectOption('高二下');
53+
await card(page, '题目').getByRole('button', { name: '保存', exact: true }).click();
54+
await expect(card(page, '题目')).toContainText('高二下');
55+
4856
// 编辑笔记并保存
4957
const notes = card(page, '你的笔记 / 答案');
5058
await notes.getByRole('button', { name: '编辑' }).click();
5159
await notes.locator('textarea').fill('注意单位换算');
5260
await notes.getByRole('button', { name: '保存' }).click();
61+
await expect(notes.getByRole('button', { name: '编辑', exact: true })).toBeVisible();
5362
await expect(notes).toContainText('注意单位换算');
5463

5564
// 取消编辑不保存修改
@@ -63,6 +72,8 @@ test('错题详情页显示各区块,编辑后保存并在刷新后保留', as
6372
await page.reload();
6473
await expect(card(page, '你的笔记 / 答案')).toContainText('注意单位换算', { timeout: 15000 });
6574
await expect(card(page, /^解析$/)).toContainText('路程等于速度乘以时间。');
75+
await expect(card(page, '题目')).toContainText('高二下');
76+
expect((await (await page.request.get(`/api/error-items/${item.id}`)).json()).gradeSemester).toBe('高二下');
6677

6778
// 删除本用例创建的错题和错题本(错题本须为空才能删除),不影响其他用例
6879
expect((await page.request.delete(`/api/error-items/${item.id}/delete`)).ok()).toBeTruthy();

‎src/__tests__/unit/correction-editor.test.tsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,33 @@ const selectPaper = (value: string) => act(async () => {
5757
});
5858

5959
describe('校对页的默认卷别与手工覆盖', () => {
60+
it('保留题目已有的年级,允许选择其他年级后保存', async () => {
61+
await renderEditor({ gradeSemester: '四年级上' });
62+
const select = container.querySelector<HTMLSelectElement>('#correction-grade-semester')!;
63+
expect(select.value).toBe('四年级上');
64+
await act(async () => {
65+
select.value = '六年级下';
66+
select.dispatchEvent(new Event('change', { bubbles: true }));
67+
});
68+
await clickButton(translations.zh.editor.save);
69+
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ gradeSemester: '六年级下' }));
70+
});
71+
72+
it('账户资料晚返回时不覆盖已经手工修改的年级', async () => {
73+
let resolveProfile!: (profile: { educationStage: string; enrollmentYear: number }) => void;
74+
const pending = new Promise<{ educationStage: string; enrollmentYear: number }>((resolve) => { resolveProfile = resolve; });
75+
vi.mocked(apiClient.get).mockImplementation(async (url) => url === '/api/notebooks' ? [] : pending);
76+
await renderEditor();
77+
const select = container.querySelector<HTMLSelectElement>('#correction-grade-semester')!;
78+
await act(async () => {
79+
select.value = '五年级下';
80+
select.dispatchEvent(new Event('change', { bubbles: true }));
81+
resolveProfile({ educationStage: 'junior_high', enrollmentYear: 2024 });
82+
});
83+
expect(select.value).toBe('五年级下');
84+
vi.mocked(apiClient.get).mockImplementation(async (url) => url === '/api/notebooks' ? [] : {});
85+
});
86+
6087
it.each([[1, 'a'], [2, 'a'], [3, 'a'], [4, 'b'], [5, 'b']] as const)('%i 星校对题默认显示并保存为 %s 卷', async (difficultyStars, paperLevel) => {
6188
await renderEditor({ difficultyStars });
6289
expect(paperSelect().value).toBe(paperLevel);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { act } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
import type { Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { QuestionMetadata } from '@/components/error-detail/question-metadata';
6+
import type { ErrorItemDetail } from '@/components/error-detail/types';
7+
import { LanguageProvider } from '@/contexts/LanguageContext';
8+
import { apiClient } from '@/lib/api-client';
9+
10+
vi.mock('@/lib/api-client', () => ({ apiClient: { put: vi.fn() } }));
11+
vi.mock('@/components/notebook-selector', () => ({ NotebookSelector: () => null }));
12+
vi.mock('@/lib/feedback', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
13+
14+
let container: HTMLDivElement;
15+
let root: Root;
16+
const onChanged = vi.fn();
17+
18+
beforeEach(() => {
19+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
20+
container = document.createElement('div');
21+
document.body.appendChild(container);
22+
root = createRoot(container);
23+
});
24+
25+
afterEach(async () => {
26+
await act(async () => root.unmount());
27+
container.remove();
28+
vi.unstubAllGlobals();
29+
});
30+
31+
async function render(gradeSemester = '初一,上期') {
32+
const item: ErrorItemDetail = { id: 'e1', questionText: '题目', answerText: '', analysis: '', knowledgePoints: '[]', tags: [], masteryLevel: 0, originalImageUrl: '', userNotes: null, gradeSemester };
33+
await act(async () => root.render(<LanguageProvider><QuestionMetadata item={item} onChanged={onChanged} /></LanguageProvider>));
34+
}
35+
36+
async function click(label: string) {
37+
await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === label)!.click());
38+
}
39+
40+
describe('题目年级编辑入口', () => {
41+
it('独立入口可修改未归类题目的年级并保存', async () => {
42+
await render();
43+
await click('编辑年级/学期');
44+
const select = container.querySelector<HTMLSelectElement>('#detail-grade-semester')!;
45+
expect(select.value).toBe('初一,上期');
46+
await act(async () => {
47+
select.value = '高二下';
48+
select.dispatchEvent(new Event('change', { bubbles: true }));
49+
});
50+
await click('保存');
51+
expect(apiClient.put).toHaveBeenCalledWith('/api/error-items/e1', { gradeSemester: '高二下', paperLevel: 'a' });
52+
expect(onChanged).toHaveBeenCalledOnce();
53+
});
54+
55+
it('保留旧的自定义年级,取消时不保存', async () => {
56+
await render('大学拓展课程');
57+
await click('编辑年级/学期');
58+
expect(container.querySelector<HTMLSelectElement>('#detail-grade-semester')!.value).toBe('大学拓展课程');
59+
await click('取消');
60+
expect(apiClient.put).not.toHaveBeenCalled();
61+
expect(container.textContent).toContain('大学拓展课程');
62+
});
63+
});

‎src/components/correction-editor.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState, useEffect, useRef } from "react";
44
import { Loader2, RefreshCw, Save } from "lucide-react";
55
import { GeogebraDraft } from "@/components/correction-editor/geogebra-draft";
66
import { MetaFields } from "@/components/correction-editor/meta-fields";
@@ -45,9 +45,13 @@ export function CorrectionEditor({ initialData, onSave, onCancel, imagePreview,
4545
mistakeAnalysis: initialData.mistakeAnalysis || "",
4646
mistakeStatus: initialData.mistakeStatus || "unknown",
4747
subjectId: initialSubjectId,
48-
gradeSemester: "",
48+
gradeSemester: initialData.gradeSemester || "",
4949
});
50-
const update = (fields: Partial<CorrectionDraft>) => setData(prev => ({ ...prev, ...fields }));
50+
const gradeEdited = useRef(false);
51+
const update = (fields: Partial<CorrectionDraft>) => {
52+
if ('gradeSemester' in fields) gradeEdited.current = true;
53+
setData(prev => ({ ...prev, ...fields }));
54+
};
5155
// 未手工选卷时随星级更新默认值,手工选择后优先保留。
5256
const paperLevel = data.paperLevel ?? getDefaultPaperLevel(data.difficultyStars) ?? "a";
5357
const [isReanswering, setIsReanswering] = useState(false);
@@ -67,8 +71,8 @@ export function CorrectionEditor({ initialData, onSave, onCancel, imagePreview,
6771
.then(user => {
6872
setGeogebraEnabled(!!user?.enableGeogebra);
6973
if (user && user.educationStage && user.enrollmentYear) {
70-
const grade = calculateGrade(user.educationStage, user.enrollmentYear, new Date(), language, timeZone);
71-
setData(prev => ({ ...prev, gradeSemester: grade }));
74+
const grade = calculateGrade(user.educationStage, user.enrollmentYear, new Date(), 'zh', timeZone);
75+
setData(prev => prev.gradeSemester || gradeEdited.current ? prev : { ...prev, gradeSemester: grade });
7276
setEducationStage(user.educationStage);
7377
}
7478
})

‎src/components/correction-editor/meta-fields.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"use client";
22

33
import { DifficultyStars } from "@/components/difficulty-stars";
4+
import { GradeSemesterSelect } from "@/components/grade-semester-select";
45
import { NotebookSelector } from "@/components/notebook-selector";
5-
import { Input } from "@/components/ui/input";
66
import { Label } from "@/components/ui/label";
77
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
88
import { useLanguage } from "@/contexts/LanguageContext";
@@ -28,11 +28,11 @@ export function MetaFields({ data, paperLevel, onChange }: MetaFieldsProps) {
2828

2929
<div className="grid grid-cols-2 gap-4">
3030
<div className="space-y-2">
31-
<Label>{t.editor.gradeSemester || "Grade/Semester"}</Label>
32-
<Input
31+
<Label htmlFor="correction-grade-semester">{t.editor.gradeSemester || "Grade/Semester"}</Label>
32+
<GradeSemesterSelect
33+
id="correction-grade-semester"
3334
value={data.gradeSemester || ""}
34-
onChange={(e) => onChange({ gradeSemester: e.target.value })}
35-
placeholder="e.g. Junior High Grade 1, 1st Semester"
35+
onChange={(gradeSemester) => onChange({ gradeSemester })}
3636
/>
3737
</div>
3838
<div className="space-y-2">

‎src/components/error-detail/question-metadata.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { NotebookSelector } from "@/components/notebook-selector";
4-
import { Input } from "@/components/ui/input";
4+
import { GradeSemesterSelect } from "@/components/grade-semester-select";
55
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
66
import { useLanguage } from "@/contexts/LanguageContext";
77
import { apiClient } from "@/lib/api-client";
@@ -33,7 +33,12 @@ export function QuestionMetadata({ item, onChanged }: QuestionMetadataProps) {
3333
paperLevel: item.paperLevel || getDefaultPaperLevel(item.difficultyStars) || "a",
3434
}),
3535
save: async (value) => {
36-
await apiClient.put(`/api/error-items/${item.id}`, { ...value, subjectId: value.subjectId || null });
36+
// 只改年级时不重复提交错题本,未归类题目也能保存。
37+
const { subjectId, ...metadata } = value;
38+
await apiClient.put(`/api/error-items/${item.id}`, {
39+
...metadata,
40+
...(subjectId !== (item.subjectId || null) ? { subjectId } : {}),
41+
});
3742
await onChanged();
3843
},
3944
logLabel: 'metadata',
@@ -51,14 +56,14 @@ export function QuestionMetadata({ item, onChanged }: QuestionMetadataProps) {
5156
{field.editing ? (
5257
<div className="space-y-3">
5358
<div className="space-y-2"><label className="text-sm text-muted-foreground">{t.notebooks?.title || "Notebook"}</label><NotebookSelector value={field.value.subjectId || undefined} onChange={(subjectId) => update({ subjectId })} /></div>
54-
<div className="space-y-2"><label className="text-sm text-muted-foreground">{t.filter.grade}</label><Input value={field.value.gradeSemester} onChange={e => update({ gradeSemester: e.target.value })} placeholder={t.notebook?.gradeSemesterPlaceholder || "e.g. Grade 7, Semester 1"} /></div>
59+
<div className="space-y-2"><label htmlFor="detail-grade-semester" className="text-sm text-muted-foreground">{t.editor.gradeSemester}</label><GradeSemesterSelect id="detail-grade-semester" value={field.value.gradeSemester} onChange={(gradeSemester) => update({ gradeSemester })} /></div>
5560
<div className="space-y-2"><label className="text-sm text-muted-foreground">{t.filter.paperLevel}</label><Select value={field.value.paperLevel} onValueChange={(paperLevel) => update({ paperLevel })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="a">{t.editor.paperLevels?.a || "Paper A"}</SelectItem><SelectItem value="b">{t.editor.paperLevels?.b || "Paper B"}</SelectItem><SelectItem value="other">{t.editor.paperLevels?.other || "Other"}</SelectItem></SelectContent></Select></div>
5661
<SaveCancelButtons onSave={field.submit} onCancel={field.cancel} />
5762
</div>
5863
) : (
5964
<div className="space-y-2 text-sm">
6065
<div className="flex justify-between"><span className="text-muted-foreground">{t.notebooks?.title || "Notebook"}:</span><span className="font-medium">{item.subject?.name || notSet}</span></div>
61-
<div className="flex justify-between"><span className="text-muted-foreground">{t.filter.grade}:</span><span className="font-medium">{item.gradeSemester || notSet}</span></div>
66+
<div className="flex flex-wrap items-center justify-between gap-2"><span className="text-muted-foreground">{t.editor.gradeSemester}:</span><div className="flex flex-wrap items-center gap-2"><span className="font-medium">{item.gradeSemester || notSet}</span><EditButton onClick={field.start} label={t.gradeSelection.edit} /></div></div>
6267
<div className="flex justify-between"><span className="text-muted-foreground">{t.filter.paperLevel}:</span><span className="font-medium">{item.paperLevel ? (t.editor.paperLevels?.[item.paperLevel as "a" | "b" | "other"] || item.paperLevel) : notSet}</span></div>
6368
</div>
6469
)}

0 commit comments

Comments
 (0)