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) =>