From c7f0c64fc764d02945464bd41b82b79a64810823 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com>
Date: Mon, 31 Aug 2026 18:06:07 +0800
Subject: [PATCH 1/3] =?UTF-8?q?feat(web):=20=E6=81=A2=E5=A4=8D=E5=8D=95?=
=?UTF-8?q?=E8=AF=8D=E5=8D=A1=E7=89=87=E4=B8=8A=E7=9A=84=E7=AC=94=E8=AE=B0?=
=?UTF-8?q?=E6=8C=89=E9=92=AE=20StudyNoteLauncher?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- FlashReview: 卡片视图和列表视图恢复笔记按钮
- ListenIdentify: 卡片视图和列表视图恢复笔记按钮
- WordPractice: 卡片视图和列表视图恢复笔记按钮
- PreTrainingCheck: 列表视图恢复笔记按钮
- ReviewCheck: 列表视图恢复笔记按钮
- ReviewWordList: 列表视图恢复笔记按钮
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
web/src/pages/FlashReview.tsx | 17 +++++++++++++++++
web/src/pages/ListenIdentify.tsx | 18 +++++++++++++++++-
web/src/pages/PreTrainingCheck.tsx | 9 +++++++++
web/src/pages/ReviewCheck.tsx | 9 +++++++++
web/src/pages/ReviewWordList.tsx | 10 +++++++++-
web/src/pages/WordPractice.tsx | 18 +++++++++++++++++-
6 files changed, 78 insertions(+), 3 deletions(-)
diff --git a/web/src/pages/FlashReview.tsx b/web/src/pages/FlashReview.tsx
index f133a79..a92a558 100644
--- a/web/src/pages/FlashReview.tsx
+++ b/web/src/pages/FlashReview.tsx
@@ -14,6 +14,7 @@ import { displayTranslationFull, displayTranslationShort, pickPhoneticDisplay }
import { nextWordTapState, syncDetailWordWithTap } from "../utils/wordReveal";
import { applyUserWordView } from "../components/WordEditControls";
import { NoteSplitLayout } from "../components/NoteSplitLayout";
+import { StudyNoteLauncher } from "../components/StudyNotePanel";
import {
clearStudyRetryFlash,
@@ -399,6 +400,14 @@ export default function FlashReview() {
+
e.stopPropagation()}>
+
+
+
e.stopPropagation()}>
+
+
-
+ e.stopPropagation()}>
+
+
{detailMode && showAnswer && (
e.stopPropagation()}>
@@ -324,6 +332,14 @@ export default function ListenIdentify() {
+
e.stopPropagation()}>
+
+
+
e.stopPropagation()}>
+
+
+
e.stopPropagation()}>
+
+
+
e.stopPropagation()}>
+
+
+
e.stopPropagation()}>
+
+
{!manualReadMode && parseAudioUrls(cardWord.audioUrl).length > 0 && (
+
e.stopPropagation()}>
+
+
{!manualReadMode && parseAudioUrls(word.audioUrl).length > 0 && (
Date: Mon, 31 Aug 2026 18:18:35 +0800
Subject: [PATCH 2/3] =?UTF-8?q?feat(web):=20=E5=8D=95=E8=AF=8D=E7=AC=94?=
=?UTF-8?q?=E8=AE=B0=E6=8C=89=E9=92=AE=E7=82=B9=E5=87=BB=E5=90=8E=E5=88=86?=
=?UTF-8?q?=E5=B1=8F=E5=B1=95=E7=A4=BA=EF=BC=8C=E4=B8=8E=E9=9A=8F=E5=BF=83?=
=?UTF-8?q?=E8=AE=B0=E4=B8=80=E8=87=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- StudyNoteLauncher 新增 onOpen 回调,支持外部控制打开方式
- NoteContext 扩展 noteTitle 状态,openNote 支持传入标题
- NoteSplitLayout 使用 NoteContext 的 noteTitle 动态显示标题
- FlashReview/PreTrainingCheck/ReviewCheck: 通过 NoteContext.openNote 分屏
- WordPractice/ReviewWordList: 自有分屏布局支持动态切换笔记 key/title
- ListenIdentify: 新增 NoteSplitLayout 分屏布局和随心记按钮
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
web/src/components/NoteContext.tsx | 10 ++++++++--
web/src/components/NoteSplitLayout.tsx | 7 ++++---
web/src/components/StudyNotePanel.tsx | 7 ++++---
web/src/pages/FlashReview.tsx | 4 ++++
web/src/pages/ListenIdentify.tsx | 23 ++++++++++++++++++++++-
web/src/pages/PreTrainingCheck.tsx | 1 +
web/src/pages/ReviewCheck.tsx | 1 +
web/src/pages/ReviewWordList.tsx | 25 ++++++++++++++++++++-----
web/src/pages/WordPractice.tsx | 26 +++++++++++++++++++++-----
9 files changed, 85 insertions(+), 19 deletions(-)
diff --git a/web/src/components/NoteContext.tsx b/web/src/components/NoteContext.tsx
index 797f7bb..0cf7471 100644
--- a/web/src/components/NoteContext.tsx
+++ b/web/src/components/NoteContext.tsx
@@ -6,7 +6,9 @@ type NoteSide = "left" | "right";
type NoteContextValue = ReturnType & {
storageKey: string;
setStorageKey: (key: string) => void;
- openNote: (key: string) => void;
+ openNote: (key: string, title?: string) => void;
+ noteTitle: string;
+ setNoteTitle: (title: string) => void;
defaultTitle: string;
setDefaultTitle: (title: string) => void;
};
@@ -16,10 +18,12 @@ const NoteContext = createContext(null);
export function NoteProvider({ children }: { children: ReactNode }) {
const note = useSplitScreenNote("lb_global_note_width");
const [storageKey, setStorageKey] = useState("");
+ const [noteTitle, setNoteTitle] = useState("随心记");
const [defaultTitle, setDefaultTitle] = useState("随心记");
- const openNote = (key: string) => {
+ const openNote = (key: string, title?: string) => {
setStorageKey(key);
+ setNoteTitle(title ?? defaultTitle);
note.setOpen(true);
};
@@ -30,6 +34,8 @@ export function NoteProvider({ children }: { children: ReactNode }) {
storageKey,
setStorageKey,
openNote,
+ noteTitle,
+ setNoteTitle,
defaultTitle,
setDefaultTitle,
}}
diff --git a/web/src/components/NoteSplitLayout.tsx b/web/src/components/NoteSplitLayout.tsx
index c25e1de..aa205b7 100644
--- a/web/src/components/NoteSplitLayout.tsx
+++ b/web/src/components/NoteSplitLayout.tsx
@@ -9,9 +9,10 @@ type Props = {
};
export function NoteSplitLayout({ children, defaultStorageKey = "", defaultTitle = "随心记" }: Props) {
- const { open, setOpen, side, setSide, width, isDesktop, startResize, storageKey } = useNote();
+ const { open, setOpen, side, setSide, width, isDesktop, startResize, storageKey, noteTitle } = useNote();
const key = storageKey || defaultStorageKey;
+ const title = noteTitle || defaultTitle;
const split = open && isDesktop;
return (
@@ -52,7 +53,7 @@ export function NoteSplitLayout({ children, defaultStorageKey = "", defaultTitle
open={open}
onClose={() => setOpen(false)}
storageKey={key}
- title={defaultTitle}
+ title={title}
side={side}
split
onSideChange={setSide}
@@ -66,7 +67,7 @@ export function NoteSplitLayout({ children, defaultStorageKey = "", defaultTitle
open={open}
onClose={() => setOpen(false)}
storageKey={key}
- title={defaultTitle}
+ title={title}
side={side}
onSideChange={setSide}
/>
diff --git a/web/src/components/StudyNotePanel.tsx b/web/src/components/StudyNotePanel.tsx
index 3c7419f..cffb035 100644
--- a/web/src/components/StudyNotePanel.tsx
+++ b/web/src/components/StudyNotePanel.tsx
@@ -46,9 +46,10 @@ type StudyNoteLauncherProps = {
title?: string;
label?: string;
className?: string;
+ onOpen?: () => void;
};
-export function StudyNoteLauncher({ storageKey, title, label, className = "" }: StudyNoteLauncherProps) {
+export function StudyNoteLauncher({ storageKey, title, label, className = "", onOpen }: StudyNoteLauncherProps) {
const { t } = useTranslation();
const resolvedTitle = title ?? t("studyNote.title");
const resolvedLabel = label ?? t("studyNote.title");
@@ -59,13 +60,13 @@ export function StudyNoteLauncher({ storageKey, title, label, className = "" }:
- {open && (
+ {!onOpen && open && (
setOpen(false)}
diff --git a/web/src/pages/FlashReview.tsx b/web/src/pages/FlashReview.tsx
index a92a558..bb13024 100644
--- a/web/src/pages/FlashReview.tsx
+++ b/web/src/pages/FlashReview.tsx
@@ -14,6 +14,7 @@ import { displayTranslationFull, displayTranslationShort, pickPhoneticDisplay }
import { nextWordTapState, syncDetailWordWithTap } from "../utils/wordReveal";
import { applyUserWordView } from "../components/WordEditControls";
import { NoteSplitLayout } from "../components/NoteSplitLayout";
+import { useNote } from "../components/NoteContext";
import { StudyNoteLauncher } from "../components/StudyNotePanel";
import {
@@ -68,6 +69,7 @@ function mapToFlashWord(w: Record): FlashWord {
export default function FlashReview() {
const { t } = useTranslation();
const navigate = useNavigate();
+ const note = useNote();
const [words, setWords] = useState([]);
const [showCompleteDialog, setShowCompleteDialog] = useState(false);
const [annotationOpen, setAnnotationOpen] = useState(false);
@@ -406,6 +408,7 @@ export default function FlashReview() {
title={t("practice.note_title", { word: visibleWords[cardIndex].word })}
label={t("practice.note")}
className="h-9 px-2"
+ onOpen={() => note.openNote(wordNoteKey(visibleWords[cardIndex].id), t("practice.note_title", { word: visibleWords[cardIndex].word }))}
/>
note.openNote(wordNoteKey(word.id), t("practice.note_title", { word: word.word }))}
/>
([]);
const [annotationOpen, setAnnotationOpen] = useState(false);
const [viewMode, setViewMode] = useState("list");
@@ -210,6 +213,7 @@ export default function ListenIdentify() {
title={t("practice.note_title", { word: w.word })}
label={t("practice.note")}
className="h-9 px-2"
+ onOpen={() => note.openNote(wordNoteKey(w.id), t("practice.note_title", { word: w.word }))}
/>
@@ -264,6 +268,10 @@ export default function ListenIdentify() {
onOpenChange={setAnnotationOpen}
/>
+
{t("practice.batch_group", { current: batchIdx + 1, total: totalBatches })}
@@ -338,6 +346,7 @@ export default function ListenIdentify() {
title={t("practice.note_title", { word: cardWord.word })}
label={t("practice.note")}
className="h-9 px-2"
+ onOpen={() => note.openNote(wordNoteKey(cardWord.id), t("practice.note_title", { word: cardWord.word }))}
/>
)}
+
@@ -395,6 +405,17 @@ export default function ListenIdentify() {
{t("practice.expand")}
+
(note.open ? note.setOpen(false) : note.openNote(`study-note:global:${wordBookId}`, t("practice.free_note")))}
+ aria-label={t("practice.open_free_note")}
+ className="max-sm:px-2 max-sm:text-xs"
+ >
+
+ {t("practice.free_note")}
+
note.openNote(`study-note:word:${wordBookId}:${word.id}`, t("practice.note_title", { word: word.word }))}
/>
note.openNote(`study-note:word:${wordBookId}:${word.id}`, t("practice.note_title", { word: word.word }))}
/>
{
+ setActiveNoteKey(`study-note:global:${wordBookId}`);
+ setActiveNoteTitle(t("practice.free_note"));
+ setGlobalNoteOpen(true);
+ };
+ const openWordNote = (key: string, title: string) => {
+ setActiveNoteKey(key);
+ setActiveNoteTitle(title);
+ setGlobalNoteOpen(true);
+ };
+
const reviewDate = useMemo(() => {
const url = new URL(window.location.href);
const qp = url.searchParams.get("date") || "";
@@ -366,6 +380,7 @@ export default function ReviewWordList() {
title={t("practice.note_title", { word: item.word })}
label={t("practice.note")}
className="h-9 px-2"
+ onOpen={() => openWordNote(`study-note:word:${wordBookId}:${item.id}`, t("practice.note_title", { word: item.word }))}
/>
setGlobalNoteOpen(false)}
- storageKey={`study-note:global:${wordBookId}`}
- title={t("practice.free_note")}
+ storageKey={activeNoteKey}
+ title={activeNoteTitle}
side={noteSide}
split
onSideChange={setNoteSide}
@@ -454,8 +469,8 @@ export default function ReviewWordList() {
setGlobalNoteOpen(false)}
- storageKey={`study-note:global:${wordBookId}`}
- title={t("practice.free_note")}
+ storageKey={activeNoteKey}
+ title={activeNoteTitle}
side={noteSide}
onSideChange={setNoteSide}
/>
@@ -485,7 +500,7 @@ export default function ReviewWordList() {
type="button"
variant={globalNoteOpen ? "brand" : "outline"}
size="pill"
- onClick={() => setGlobalNoteOpen(true)}
+ onClick={() => (globalNoteOpen ? setGlobalNoteOpen(false) : openGlobalNote())}
aria-label={t("practice.open_free_note")}
className="max-sm:px-2 max-sm:text-xs"
>
diff --git a/web/src/pages/WordPractice.tsx b/web/src/pages/WordPractice.tsx
index 35b22ae..d9dd8d7 100644
--- a/web/src/pages/WordPractice.tsx
+++ b/web/src/pages/WordPractice.tsx
@@ -63,6 +63,20 @@ export default function WordPractice() {
startResize: startNoteResize,
} = useSplitScreenNote("lb_practice_note_width");
+ const [activeNoteKey, setActiveNoteKey] = useState(`study-note:global:${wordBookId}`);
+ const [activeNoteTitle, setActiveNoteTitle] = useState(t("practice.free_note"));
+
+ const openGlobalNote = () => {
+ setActiveNoteKey(`study-note:global:${wordBookId}`);
+ setActiveNoteTitle(t("practice.free_note"));
+ setGlobalNoteOpen(true);
+ };
+ const openWordNote = (key: string, title: string) => {
+ setActiveNoteKey(key);
+ setActiveNoteTitle(title);
+ setGlobalNoteOpen(true);
+ };
+
const handlePlayNextAudio = (word: PracticeWord) => {
if (!word.audioUrl) return;
const urls = parseAudioUrls(word.audioUrl);
@@ -371,6 +385,7 @@ export default function WordPractice() {
title={t("practice.note_title", { word: cardWord.word })}
label={t("practice.note")}
className="h-9 px-2"
+ onOpen={() => openWordNote(wordNoteKey(cardWord.id), t("practice.note_title", { word: cardWord.word }))}
/>
{!manualReadMode && parseAudioUrls(cardWord.audioUrl).length > 0 && (
@@ -433,6 +448,7 @@ export default function WordPractice() {
title={t("practice.note_title", { word: word.word })}
label={t("practice.note")}
className="h-9 px-2"
+ onOpen={() => openWordNote(wordNoteKey(word.id), t("practice.note_title", { word: word.word }))}
/>
{!manualReadMode && parseAudioUrls(word.audioUrl).length > 0 && (
@@ -484,8 +500,8 @@ export default function WordPractice() {
setGlobalNoteOpen(false)}
- storageKey={`study-note:global:${wordBookId}`}
- title={t("practice.free_note")}
+ storageKey={activeNoteKey}
+ title={activeNoteTitle}
side={noteSide}
split
onSideChange={setNoteSide}
@@ -500,8 +516,8 @@ export default function WordPractice() {
setGlobalNoteOpen(false)}
- storageKey={`study-note:global:${wordBookId}`}
- title={t("practice.free_note")}
+ storageKey={activeNoteKey}
+ title={activeNoteTitle}
side={noteSide}
onSideChange={setNoteSide}
/>
@@ -557,7 +573,7 @@ export default function WordPractice() {
type="button"
variant={globalNoteOpen ? "brand" : "outline"}
size="pill"
- onClick={() => setGlobalNoteOpen((v) => !v)}
+ onClick={() => (globalNoteOpen ? setGlobalNoteOpen(false) : openGlobalNote())}
aria-label={t("practice.open_free_note")}
className="max-sm:px-2 max-sm:text-xs"
>
From 037f7eca019e7faf0513c900497b5be091a20f6e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=9D=B0?= <17793426456@163.com>
Date: Mon, 31 Aug 2026 18:24:37 +0800
Subject: [PATCH 3/3] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=20WordPracti?=
=?UTF-8?q?ce=20=E4=B8=AD=20wordBookId=20TDZ=20=E9=94=99=E8=AF=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
将 wordBookId/mode/wordNoteKey 定义移到 activeNoteKey 之前,
避免 const 暂时性死区导致 'Cannot access before initialization'
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
web/src/pages/WordPractice.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/web/src/pages/WordPractice.tsx b/web/src/pages/WordPractice.tsx
index d9dd8d7..a9326f7 100644
--- a/web/src/pages/WordPractice.tsx
+++ b/web/src/pages/WordPractice.tsx
@@ -63,6 +63,10 @@ export default function WordPractice() {
startResize: startNoteResize,
} = useSplitScreenNote("lb_practice_note_width");
+ const mode = useMemo(() => sessionStorage.getItem("lb_mode") || "study", []);
+ const wordBookId = useMemo(() => Number(sessionStorage.getItem("lb_wordbook_id") || 0), []);
+ const wordNoteKey = (wordId: number) => `study-note:word:${wordBookId}:${wordId}`;
+
const [activeNoteKey, setActiveNoteKey] = useState(`study-note:global:${wordBookId}`);
const [activeNoteTitle, setActiveNoteTitle] = useState(t("practice.free_note"));
@@ -93,10 +97,6 @@ export default function WordPractice() {
setAudioIndexMap(new Map(audioIndexMap).set(word.id, next));
};
- const mode = useMemo(() => sessionStorage.getItem("lb_mode") || "study", []);
- const wordBookId = useMemo(() => Number(sessionStorage.getItem("lb_wordbook_id") || 0), []);
- const wordNoteKey = (wordId: number) => `study-note:word:${wordBookId}:${wordId}`;
-
const applyPatchedWord = (view: UserWordView) => {
const e = view.effective;
setWords((prev) =>