diff --git a/src/components/practice/ProficientInputArea.tsx b/src/components/practice/ProficientInputArea.tsx new file mode 100644 index 0000000..8f48e8e --- /dev/null +++ b/src/components/practice/ProficientInputArea.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef } from 'react' +import clsx from 'clsx' +import { AnimatePresence, motion } from 'framer-motion' + +interface ProficientInputAreaProps { + value: string + isComposing: boolean + inputLocked: boolean + hasError: boolean + statusText?: string | null + onChange: (value: string) => void + onCompositionStart: () => void + onCompositionEnd: (value: string) => void +} + +export function ProficientInputArea({ + value, + isComposing, + inputLocked, + hasError, + statusText, + onChange, + onCompositionStart, + onCompositionEnd, +}: ProficientInputAreaProps) { + const inputRef = useRef(null) + + useEffect(() => { + if (inputLocked) return + inputRef.current?.focus() + }, [inputLocked, value]) + + return ( +
+ onChange(e.target.value)} + onCompositionStart={onCompositionStart} + onCompositionEnd={(e) => onCompositionEnd(e.currentTarget.value)} + disabled={inputLocked} + placeholder="输入当前分组中的一个完整条目,停顿 800ms 自动核对" + className={clsx( + 'w-full rounded-xl border px-4 py-3 text-base md:text-lg', + 'bg-white dark:bg-gray-800/80 text-gray-900 dark:text-gray-100', + 'transition-all duration-200 focus:outline-none focus:ring-2', + isComposing && 'border-gray-300 dark:border-gray-600 opacity-80', + !isComposing && !hasError && 'border-gray-300 dark:border-gray-600 focus:ring-indigo-400 focus:border-indigo-400', + !isComposing && hasError && 'border-red-400 focus:ring-red-400 focus:border-red-400', + inputLocked && 'cursor-not-allowed opacity-60' + )} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> + +
+ + {inputLocked ? ( + + 答案显示中,输入已锁定 + + ) : statusText ? ( + + {statusText} + + ) : null} + +
+
+ ) +} diff --git a/src/data/changelog.json b/src/data/changelog.json index 83a6737..e715826 100644 --- a/src/data/changelog.json +++ b/src/data/changelog.json @@ -1,5 +1,12 @@ { "changelogEntries": [ + { + "id": "2026-03-23-process-detail-proficient-mode", + "date": "2026-03-23", + "type": "feat", + "title": "过程详情练习新增熟练模式,并优化答案展示后的输入恢复与界面层次", + "scope": "过程详情" + }, { "id": "2026-03-19-quality-tool-update", "date": "2026-03-19", diff --git a/src/pages/ProcessDetailPage.tsx b/src/pages/ProcessDetailPage.tsx index 4d0cfd9..232b9c1 100644 --- a/src/pages/ProcessDetailPage.tsx +++ b/src/pages/ProcessDetailPage.tsx @@ -1,11 +1,24 @@ import { useParams, Link, useLocation, useNavigate } from 'react-router-dom' import { motion } from 'framer-motion' -import { ArrowLeft, ArrowRight, FileText, Wrench, FileOutput, LayoutGrid, Eye, EyeOff, Info, Check } from 'lucide-react' +import { + ArrowLeft, + ArrowRight, + FileText, + Wrench, + FileOutput, + LayoutGrid, + Eye, + EyeOff, + Info, + Check, +} from 'lucide-react' import { getProcessDetail, processes } from '@/data' import { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { InputArea } from '@/components/practice/InputArea' +import { ProficientInputArea } from '@/components/practice/ProficientInputArea' import { normalizeAnswer } from '@/utils/practice' import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation' +import { useAppStore } from '@/stores/useAppStore' type IttoSection = 'inputs' | 'tools' | 'outputs' type CharStatus = 'pending' | 'correct' | 'error' @@ -15,25 +28,74 @@ interface PracticeItem { section: IttoSection name: string normalizedAnswer: string - originalData: any // 保留原始数据(包含 detail、nameEn、note) + originalData: any } const STORAGE_KEY = 'ittoview:process-detail:itto-visibility' +const SECTION_ORDER: IttoSection[] = ['inputs', 'tools', 'outputs'] +const SECTION_LABELS: Record = { + inputs: '输入', + tools: '工具与技术', + outputs: '输出', +} +const SECTION_THEME: Record = { + inputs: { + header: 'bg-blue-50 dark:bg-blue-900/30 border-blue-100 dark:border-blue-800', + icon: 'text-blue-600 dark:text-blue-400', + accent: 'text-blue-900 dark:text-blue-100', + button: 'text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50', + }, + tools: { + header: 'bg-amber-50 dark:bg-amber-900/30 border-amber-100 dark:border-amber-800', + icon: 'text-amber-600 dark:text-amber-400', + accent: 'text-amber-900 dark:text-amber-100', + button: 'text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50', + }, + outputs: { + header: 'bg-emerald-50 dark:bg-emerald-900/30 border-emerald-100 dark:border-emerald-800', + icon: 'text-emerald-600 dark:text-emerald-400', + accent: 'text-emerald-900 dark:text-emerald-100', + button: 'text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50', + }, +} + +function getSectionIcon(section: IttoSection) { + if (section === 'inputs') return FileText + if (section === 'tools') return Wrench + return FileOutput +} + +function getFirstAvailableSection(itemsBySection: Record): IttoSection { + return SECTION_ORDER.find((section) => itemsBySection[section].length > 0) ?? 'inputs' +} + +function getNextSection( + currentSection: IttoSection, + itemsBySection: Record, + answeredItems: Set +): IttoSection | null { + const currentIndex = SECTION_ORDER.indexOf(currentSection) + for (let index = currentIndex + 1; index < SECTION_ORDER.length; index += 1) { + const nextSection = SECTION_ORDER[index] + const remaining = itemsBySection[nextSection].some((item) => !answeredItems.has(item.id)) + if (remaining) return nextSection + } + return null +} export function ProcessDetailPage() { const { id } = useParams() const location = useLocation() const navigate = useNavigate() + const { practiceMode } = useAppStore() const processDetail = useMemo(() => (id ? getProcessDetail(id) : null), [id]) - // ITTO 显示/隐藏状态管理 const [visible, setVisible] = useState>(() => { const defaultVisible = { inputs: true, tools: true, outputs: true } try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return defaultVisible const parsed = JSON.parse(raw) - // 数据校验:确保是对象且包含正确的键 if (typeof parsed !== 'object' || parsed === null) return defaultVisible return { inputs: typeof parsed.inputs === 'boolean' ? parsed.inputs : true, @@ -45,18 +107,16 @@ export function ProcessDetailPage() { } }) - // 持久化状态 useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(visible)) } catch (error) { - // 在隐私模式或受限环境下,localStorage 可能不可用 console.warn('无法保存 ITTO 显示状态:', error) } }, [visible]) const toggleSection = (key: IttoSection) => { - setVisible(prev => ({ ...prev, [key]: !prev[key] })) + setVisible((prev) => ({ ...prev, [key]: !prev[key] })) } const allVisible = Object.values(visible).every(Boolean) @@ -65,22 +125,22 @@ export function ProcessDetailPage() { setVisible({ inputs: newState, tools: newState, outputs: newState }) } - // ── 练习条目 ────────────────────────────────────────────────────────────── const practiceItems = useMemo(() => { if (!processDetail) return [] const buildItems = (details: any[] | undefined, section: IttoSection): PracticeItem[] => { if (!details) return [] - return details.map((d: any, i: number) => { - const name: string = d?.name || d?.id || `项${i + 1}` + return details.map((detail: any, index: number) => { + const name: string = detail?.name || detail?.id || `项${index + 1}` return { - id: `${section}-${d?.id || i}`, + id: `${section}-${detail?.id || index}`, section, name, normalizedAnswer: normalizeAnswer(name, false), - originalData: d, // 保留原始数据 + originalData: detail, } }) } + return [ ...buildItems(processDetail.inputDetails, 'inputs'), ...buildItems(processDetail.toolDetails, 'tools'), @@ -88,7 +148,15 @@ export function ProcessDetailPage() { ] }, [processDetail]) - // ── 练习模式状态 ────────────────────────────────────────────────────────── + const itemsBySection = useMemo>( + () => ({ + inputs: practiceItems.filter((item) => item.section === 'inputs'), + tools: practiceItems.filter((item) => item.section === 'tools'), + outputs: practiceItems.filter((item) => item.section === 'outputs'), + }), + [practiceItems] + ) + const [isPracticeMode, setIsPracticeMode] = useState(false) const [answeredItems, setAnsweredItems] = useState>(new Set()) const [currentPracticeId, setCurrentPracticeId] = useState(null) @@ -97,13 +165,20 @@ export function ProcessDetailPage() { const [isComposing, setIsComposing] = useState(false) const isComposingRef = useRef(false) const latestInputRef = useRef([]) + + const [currentSection, setCurrentSection] = useState('inputs') + const [proficientInput, setProficientInput] = useState('') + const [proficientHasError, setProficientHasError] = useState(false) + const [proficientStatusText, setProficientStatusText] = useState(null) + const [inputLocked, setInputLocked] = useState(false) const [lastErrorTimestamp, setLastErrorTimestamp] = useState(null) const [showAnswer, setShowAnswer] = useState(false) const longPressTimerRef = useRef(null) const autoAdvanceTimerRef = useRef(null) + const answerRevealTimerRef = useRef(null) + const debounceTimerRef = useRef(null) - // 庆祝动画 const [showCelebration, setShowCelebration] = useState(false) const currentPracticeItem = useMemo( @@ -112,13 +187,17 @@ export function ProcessDetailPage() { ) const currentPracticeIndex = practiceItems.findIndex((item) => item.id === currentPracticeId) - // 清理定时器 + const currentSectionItems = itemsBySection[currentSection] + const currentSectionRemainingItems = currentSectionItems.filter((item) => !answeredItems.has(item.id)) + const completedCount = answeredItems.size + const clearLongPressTimer = useCallback(() => { if (longPressTimerRef.current !== null) { window.clearTimeout(longPressTimerRef.current) longPressTimerRef.current = null } }, []) + const clearAutoAdvanceTimer = useCallback(() => { if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current) @@ -126,16 +205,44 @@ export function ProcessDetailPage() { } }, []) + const clearAnswerRevealTimer = useCallback(() => { + if (answerRevealTimerRef.current !== null) { + window.clearTimeout(answerRevealTimerRef.current) + answerRevealTimerRef.current = null + } + }, []) + + const clearDebounceTimer = useCallback(() => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = null + } + }, []) + useEffect(() => { - return () => { clearLongPressTimer(); clearAutoAdvanceTimer() } - }, [clearLongPressTimer, clearAutoAdvanceTimer]) + return () => { + clearLongPressTimer() + clearAutoAdvanceTimer() + clearAnswerRevealTimer() + clearDebounceTimer() + } + }, [clearLongPressTimer, clearAutoAdvanceTimer, clearAnswerRevealTimer, clearDebounceTimer]) - useEffect(() => { latestInputRef.current = userInput }, [userInput]) + useEffect(() => { + latestInputRef.current = userInput + }, [userInput]) + + const hideAnswerAndUnlock = useCallback(() => { + clearAnswerRevealTimer() + setShowAnswer(false) + setInputLocked(false) + }, [clearAnswerRevealTimer]) - // ── 开始 / 退出练习(必须在 validateInput 之前定义,避免闭包捕获 undefined)── const exitPractice = useCallback(() => { clearAutoAdvanceTimer() clearLongPressTimer() + clearAnswerRevealTimer() + clearDebounceTimer() setIsPracticeMode(false) setAnsweredItems(new Set()) setCurrentPracticeId(null) @@ -143,110 +250,135 @@ export function ProcessDetailPage() { setCharStatuses([]) setIsComposing(false) isComposingRef.current = false + setCurrentSection(getFirstAvailableSection(itemsBySection)) + setProficientInput('') + setProficientHasError(false) + setProficientStatusText(null) setLastErrorTimestamp(null) setShowAnswer(false) setInputLocked(false) - }, [clearAutoAdvanceTimer, clearLongPressTimer]) + }, [ + clearAutoAdvanceTimer, + clearLongPressTimer, + clearAnswerRevealTimer, + clearDebounceTimer, + itemsBySection, + ]) + + const finishPractice = useCallback(() => { + clearAutoAdvanceTimer() + autoAdvanceTimerRef.current = window.setTimeout(() => { + setShowCelebration(true) + window.setTimeout(() => { + setShowCelebration(false) + exitPractice() + }, 2000) + }, 500) + }, [clearAutoAdvanceTimer, exitPractice]) const startPractice = useCallback(() => { if (practiceItems.length === 0) return clearAutoAdvanceTimer() + clearAnswerRevealTimer() + clearDebounceTimer() setIsPracticeMode(true) setAnsweredItems(new Set()) - setCurrentPracticeId(practiceItems[0].id) - }, [practiceItems, clearAutoAdvanceTimer]) + setShowAnswer(false) + setInputLocked(false) + setLastErrorTimestamp(null) + + if (practiceMode === 'proficient') { + setCurrentSection(getFirstAvailableSection(itemsBySection)) + setCurrentPracticeId(null) + setProficientInput('') + setProficientHasError(false) + setProficientStatusText('当前分组内可乱序输入,停顿 800ms 自动核对。') + } else { + setCurrentPracticeId(practiceItems[0].id) + setProficientInput('') + setProficientHasError(false) + setProficientStatusText(null) + } + }, [ + practiceItems, + practiceMode, + itemsBySection, + clearAutoAdvanceTimer, + clearAnswerRevealTimer, + clearDebounceTimer, + ]) - // 切换到某个练习项时重置输入状态,并聚焦第一个输入框 - // 依赖 currentPracticeId 和名称长度,而非 currentPracticeItem 对象引用(避免每次渲染重置) const currentPracticeNameLength = currentPracticeItem?.name.length ?? 0 useEffect(() => { - if (!isPracticeMode || !currentPracticeId || currentPracticeNameLength === 0) return + if (!isPracticeMode || practiceMode !== 'standard' || !currentPracticeId || currentPracticeNameLength === 0) return setUserInput(new Array(currentPracticeNameLength).fill('')) setCharStatuses(new Array(currentPracticeNameLength).fill('pending')) setLastErrorTimestamp(null) setShowAnswer(false) setInputLocked(false) - // 延迟聚焦,等 DOM 更新后再聚焦第一个输入框 setTimeout(() => { - const firstInput = document.querySelector( - '.practice-input-area input' - ) as HTMLInputElement + const firstInput = document.querySelector('.practice-input-area input') as HTMLInputElement | null firstInput?.focus() }, 150) - }, [isPracticeMode, currentPracticeId, currentPracticeNameLength]) + }, [isPracticeMode, practiceMode, currentPracticeId, currentPracticeNameLength]) - // 切换过程时自动退出练习(避免定时器跨过程触发) useEffect(() => { exitPractice() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]) + }, [id, practiceMode, exitPractice]) - // Ctrl+H 快捷键显示/隐藏答案 - useEffect(() => { + const revealAnswersForThreeSeconds = useCallback(() => { if (!isPracticeMode) return + clearLongPressTimer() + clearDebounceTimer() + clearAnswerRevealTimer() - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey && e.key === 'h') { - e.preventDefault() - if (!showAnswer) { - setShowAnswer(true) - setInputLocked(true) - } - } + if (practiceMode === 'proficient') { + setProficientInput('') + setProficientHasError(false) } - const handleKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Control' || e.key === 'h') { - if (showAnswer) { - setShowAnswer(false) - setInputLocked(false) - } - } - } + setShowAnswer(true) + setInputLocked(true) + answerRevealTimerRef.current = window.setTimeout(() => { + setShowAnswer(false) + setInputLocked(false) + const input = document.querySelector('.practice-input-area input') as HTMLInputElement | null + input?.focus() + }, 3000) + }, [ + isPracticeMode, + practiceMode, + clearLongPressTimer, + clearDebounceTimer, + clearAnswerRevealTimer, + ]) - window.addEventListener('keydown', handleKeyDown) - window.addEventListener('keyup', handleKeyUp) - - return () => { - window.removeEventListener('keydown', handleKeyDown) - window.removeEventListener('keyup', handleKeyUp) - } - }, [isPracticeMode, showAnswer]) - - - // ── 验证逻辑 ────────────────────────────────────────────────────────────── const validateInput = useCallback( (input: string[]) => { if (!currentPracticeItem) return const originalAnswer = currentPracticeItem.name const normalizedInput = normalizeAnswer(input.join(''), false) - const normalizedAns = currentPracticeItem.normalizedAnswer - const normalizeChar = (c: string) => normalizeAnswer(c, false) || c + const normalizedAnswer = currentPracticeItem.normalizedAnswer + const normalizeChar = (char: string) => normalizeAnswer(char, false) || char - const newStatuses = input.map((char, i) => { + const newStatuses = input.map((char, index) => { if (!char) return 'pending' - const expected = originalAnswer[i] ?? '' + const expected = originalAnswer[index] ?? '' if (!expected) return 'error' return normalizeChar(char) === normalizeChar(expected) ? 'correct' : 'error' }) setCharStatuses(newStatuses) - const isComplete = input.every((c) => c !== '') && input.length === originalAnswer.length - const isCorrect = isComplete && normalizedInput === normalizedAns + const isComplete = input.every((char) => char !== '') && input.length === originalAnswer.length + const isCorrect = isComplete && normalizedInput === normalizedAnswer if (isCorrect) { - setAnsweredItems((prev) => new Set(prev).add(currentPracticeItem.id)) + const nextAnsweredItems = new Set(answeredItems) + nextAnsweredItems.add(currentPracticeItem.id) + setAnsweredItems(nextAnsweredItems) clearAutoAdvanceTimer() if (currentPracticeIndex === practiceItems.length - 1) { - // 最后一个条目,显示庆祝动画 - autoAdvanceTimerRef.current = window.setTimeout(() => { - setShowCelebration(true) - // 庆祝动画结束后退出练习 - setTimeout(() => { - setShowCelebration(false) - exitPractice() - }, 2000) - }, 500) + finishPractice() } else { autoAdvanceTimerRef.current = window.setTimeout(() => { setCurrentPracticeId(practiceItems[currentPracticeIndex + 1].id) @@ -256,7 +388,7 @@ export function ProcessDetailPage() { setLastErrorTimestamp(Date.now()) } }, - [currentPracticeItem, currentPracticeIndex, practiceItems, clearAutoAdvanceTimer, exitPractice] + [answeredItems, currentPracticeItem, currentPracticeIndex, practiceItems, clearAutoAdvanceTimer, finishPractice] ) const handleInputChange = useCallback( @@ -269,21 +401,31 @@ export function ProcessDetailPage() { [validateInput] ) - const handleCompositionStart = useCallback((_index: number) => { + const handleCompositionStart = useCallback((_index?: number) => { isComposingRef.current = true setIsComposing(true) - }, []) + clearDebounceTimer() + }, [clearDebounceTimer]) const handleCompositionEnd = useCallback( - (index: number, value: string) => { + (indexOrValue: number | string, maybeValue?: string) => { isComposingRef.current = false setIsComposing(false) + + if (practiceMode === 'proficient') { + const nextValue = typeof indexOrValue === 'string' ? indexOrValue : maybeValue ?? '' + setProficientInput(nextValue) + return + } + + const index = indexOrValue as number + const value = maybeValue ?? '' requestAnimationFrame(() => { - const cur = latestInputRef.current - const newInput = [...cur] + const current = latestInputRef.current + const newInput = [...current] if (value) { - value.split('').forEach((ch, i) => { - if (index + i < newInput.length) newInput[index + i] = ch + value.split('').forEach((char, offset) => { + if (index + offset < newInput.length) newInput[index + offset] = char }) } else { newInput[index] = '' @@ -293,7 +435,7 @@ export function ProcessDetailPage() { validateInput(newInput) }) }, - [validateInput] + [practiceMode, validateInput] ) const handlePaste = useCallback( @@ -301,28 +443,80 @@ export function ProcessDetailPage() { if (!currentPracticeItem) return e.preventDefault() const text = e.clipboardData.getData('text') - const targetLen = currentPracticeItem.name.length - const newInput = new Array(targetLen).fill('') - text.split('').forEach((ch, i) => { if (i < targetLen) newInput[i] = ch }) + const targetLength = currentPracticeItem.name.length + const newInput = new Array(targetLength).fill('') + text.split('').forEach((char, index) => { + if (index < targetLength) newInput[index] = char + }) handleInputChange(newInput) }, [currentPracticeItem, handleInputChange] ) - // ── 长按显示答案 ────────────────────────────────────────────────────────── - const handleLongPressStart = useCallback(() => { - clearLongPressTimer() - longPressTimerRef.current = window.setTimeout(() => { - setShowAnswer(true) - setInputLocked(true) - }, 300) - }, [clearLongPressTimer]) + const submitProficientAnswer = useCallback((rawValue: string) => { + const normalizedValue = normalizeAnswer(rawValue, false) + if (!normalizedValue || inputLocked) return - const handleLongPressEnd = useCallback(() => { - clearLongPressTimer() - setShowAnswer(false) - setInputLocked(false) - }, [clearLongPressTimer]) + const targetItems = itemsBySection[currentSection] + const matchedItem = targetItems.find( + (item) => !answeredItems.has(item.id) && item.normalizedAnswer === normalizedValue + ) + + if (!matchedItem) { + setProficientHasError(true) + setProficientStatusText(`未匹配到当前${SECTION_LABELS[currentSection]}分组中的条目,请继续修改。`) + return + } + + const nextAnsweredItems = new Set(answeredItems) + nextAnsweredItems.add(matchedItem.id) + setAnsweredItems(nextAnsweredItems) + setProficientInput('') + setProficientHasError(false) + + const currentSectionDone = itemsBySection[currentSection].every((item) => nextAnsweredItems.has(item.id)) + const allDone = practiceItems.every((item) => nextAnsweredItems.has(item.id)) + + if (allDone) { + setProficientStatusText('全部条目已完成,正在结束练习。') + finishPractice() + return + } + + if (currentSectionDone) { + const nextSection = getNextSection(currentSection, itemsBySection, nextAnsweredItems) + if (nextSection) { + setCurrentSection(nextSection) + setProficientStatusText(`当前${SECTION_LABELS[currentSection]}已完成,已切换到${SECTION_LABELS[nextSection]}。`) + } else { + setProficientStatusText(`当前${SECTION_LABELS[currentSection]}已完成。`) + } + return + } + + const doneCount = itemsBySection[currentSection].filter((item) => nextAnsweredItems.has(item.id)).length + setProficientStatusText(`已命中:${matchedItem.name}(${SECTION_LABELS[currentSection]} ${doneCount}/${itemsBySection[currentSection].length})`) + }, [answeredItems, currentSection, inputLocked, itemsBySection, practiceItems, finishPractice]) + + useEffect(() => { + if (!isPracticeMode || practiceMode !== 'proficient' || isComposing || inputLocked) return + clearDebounceTimer() + if (!normalizeAnswer(proficientInput, false)) return + + debounceTimerRef.current = window.setTimeout(() => { + submitProficientAnswer(proficientInput) + }, 800) + + return clearDebounceTimer + }, [ + isPracticeMode, + practiceMode, + proficientInput, + isComposing, + inputLocked, + submitProficientAnswer, + clearDebounceTimer, + ]) if (!processDetail) { return ( @@ -339,14 +533,14 @@ export function ProcessDetailPage() { const pg = processDetail.processGroup const purpose = (processDetail as any).purpose - const currentIndex = processes.findIndex(p => p.id === id) + const currentIndex = processes.findIndex((process) => process.id === id) const prevProcess = currentIndex > 0 ? processes[currentIndex - 1] : null const nextProcess = currentIndex < processes.length - 1 ? processes[currentIndex + 1] : null const fromMatrix = location.state?.from === 'matrix' + const practiceModeLabel = practiceMode === 'proficient' ? '熟练模式' : '标准模式' return ( -
- {/* 返回按钮 + 面包屑 */} +
{fromMatrix && (
- {/* 过程标题 - 更紧凑 */} {processDetail.name}

{processDetail.nameEn}

-
+
{ka && ( {ka.name} @@ -398,6 +591,9 @@ export function ProcessDetailPage() { {pg.name} )} + + {practiceModeLabel} + - )} -
- - {isPracticeMode ? ( - it.section === 'inputs')} - answeredItems={answeredItems} - currentPracticeId={currentPracticeId} - showAnswer={showAnswer} - onLongPressStart={handleLongPressStart} - onLongPressEnd={handleLongPressEnd} - /> - ) : visible.inputs ? ( -
    - {processDetail.inputDetails?.map((inputDetail: any) => { - const hasDetail = inputDetail.detail && inputDetail.detail.length > 0 - return ( -
  • -
    {inputDetail.name || inputDetail.id}
    - {inputDetail.nameEn &&
    {inputDetail.nameEn}
    } - {hasDetail && ( -
    - {inputDetail.detail.map((item: any, idx: number) => ( - - {item.label} - {idx < inputDetail.detail.length - 1 && '、'} - - ))} -
    - )} - {inputDetail.note && ( -
    - 💡 {inputDetail.note} -
    - )} -
  • - ) - })} -
- ) : ( -
- )} - -
+ {SECTION_ORDER.map((section) => { + const theme = SECTION_THEME[section] + const Icon = getSectionIcon(section) + const sectionItems = itemsBySection[section] + const sectionAnsweredCount = sectionItems.filter((item) => answeredItems.has(item.id)).length + const isCurrentSection = practiceMode === 'proficient' + ? currentSection === section + : currentPracticeItem?.section === section + const isVisible = isPracticeMode ? true : visible[section] + const canSwitchSection = isPracticeMode && practiceMode === 'proficient' && sectionItems.length > 0 - {/* 工具与技术 */} -
-
-
- -

工具与技术 ({processDetail.tools.length})

-
- {!isPracticeMode && ( - + {!isPracticeMode ? ( + + ) : practiceMode === 'proficient' && canSwitchSection ? ( + + {isCurrentSection ? '当前分组' : '点击切换'} + + ) : null} +
+ - {visible.tools ? : } - {visible.tools ? '隐藏' : '显示'} - - )} -
- - {isPracticeMode ? ( - it.section === 'tools')} - answeredItems={answeredItems} - currentPracticeId={currentPracticeId} - showAnswer={showAnswer} - onLongPressStart={handleLongPressStart} - onLongPressEnd={handleLongPressEnd} - /> - ) : visible.tools ? ( -
    - {processDetail.toolDetails?.map((toolDetail: any) => { - const hasDetail = toolDetail.detail && toolDetail.detail.length > 0 - return ( -
  • -
    {toolDetail.name || toolDetail.id}
    - {toolDetail.nameEn &&
    {toolDetail.nameEn}
    } - {hasDetail && ( -
    - {toolDetail.detail.map((item: any, idx: number) => ( - - {item.label} - {idx < toolDetail.detail.length - 1 && '、'} - - ))} -
    - )} - {toolDetail.note && ( -
    - 💡 {toolDetail.note} -
    - )} -
  • - ) - })} -
- ) : ( -
- )} - -
- - {/* 输出 */} -
-
-
- -

输出 ({processDetail.outputs.length})

+ {isPracticeMode ? ( + { + clearLongPressTimer() + longPressTimerRef.current = window.setTimeout(() => { + setShowAnswer(true) + setInputLocked(true) + }, 300) + }) : undefined} + onLongPressEnd={practiceMode === 'standard' ? hideAnswerAndUnlock : undefined} + /> + ) : ( + + )} +
- {!isPracticeMode && ( - - )} -
- - {isPracticeMode ? ( - it.section === 'outputs')} - answeredItems={answeredItems} - currentPracticeId={currentPracticeId} - showAnswer={showAnswer} - onLongPressStart={handleLongPressStart} - onLongPressEnd={handleLongPressEnd} - /> - ) : visible.outputs ? ( -
    - {processDetail.outputDetails?.map((outputDetail: any) => { - const hasDetail = outputDetail.detail && outputDetail.detail.length > 0 - return ( -
  • -
    {outputDetail.name || outputDetail.id}
    - {outputDetail.nameEn &&
    {outputDetail.nameEn}
    } - {hasDetail && ( -
    - {outputDetail.detail.map((item: any, idx: number) => ( - - {item.label} - {idx < outputDetail.detail.length - 1 && '、'} - - ))} -
    - )} - {outputDetail.note && ( -
    - 💡 {outputDetail.note} -
    - )} -
  • - ) - })} -
- ) : ( -
- )} - -
+ ) + })}
- {/* 练习模式底部输入区域 */} - {isPracticeMode && currentPracticeItem && ( -
-
- - 第 {currentPracticeIndex + 1} 项 / 共 {practiceItems.length} 项 - - {showAnswer && ( - - 答案:{currentPracticeItem.name} - - )} -
-
- + {isPracticeMode && ( +
+
+
+
+ 已完成 {completedCount} / 共 {practiceItems.length} 项 + {practiceMode === 'proficient' && ( + · 当前分组:{SECTION_LABELS[currentSection]}({itemsBySection[currentSection].filter((item) => answeredItems.has(item.id)).length}/{itemsBySection[currentSection].length}) + )} + {practiceMode === 'standard' && currentPracticeItem && ( + · 第 {currentPracticeIndex + 1} 项 + )} +
+ {showAnswer && practiceMode === 'proficient' && ( +
+ 当前显示:{SECTION_LABELS[currentSection]}分组未完成答案({currentSectionRemainingItems.length} 项) +
+ )} + {showAnswer && practiceMode === 'standard' && currentPracticeItem && ( +
+ 当前答案:{currentPracticeItem.name} +
+ )} +
+ +
+ {practiceMode === 'proficient' ? ( + { + setProficientInput(value) + setProficientHasError(false) + if (!value) { + setProficientStatusText('当前分组内可乱序输入,停顿 800ms 自动核对。') + } + }} + onCompositionStart={() => handleCompositionStart()} + onCompositionEnd={(value) => handleCompositionEnd(value)} + /> + ) : currentPracticeItem ? ( + handleCompositionStart(index)} + onCompositionEnd={(index, value) => handleCompositionEnd(index, value)} + onPaste={handlePaste} + /> + ) : null} +
)} - {/* 前后导航 - 更紧凑 */} } - {/* 庆祝动画 */} {showCelebration && ( setShowCelebration(false)} /> )} @@ -764,30 +863,77 @@ export function ProcessDetailPage() { ) } -// ── 练习列表子组件 ────────────────────────────────────────────────────────── +interface StaticIttoListProps { + items: PracticeItem[] +} + +function StaticIttoList({ items }: StaticIttoListProps) { + return ( +
    + {items.map((item) => { + const data = item.originalData + const hasDetail = data?.detail && data.detail.length > 0 + + return ( +
  • +
    {data?.name || data?.id}
    + {data?.nameEn &&
    {data.nameEn}
    } + {hasDetail && ( +
    + {data.detail.map((detailItem: any, idx: number) => ( + + {detailItem.label} + {idx < data.detail.length - 1 && '、'} + + ))} +
    + )} + {data?.note && ( +
    + 💡 {data.note} +
    + )} +
  • + ) + })} +
+ ) +} + interface PracticeListProps { items: PracticeItem[] answeredItems: Set + mode: 'standard' | 'proficient' currentPracticeId: string | null + currentSection: IttoSection + section: IttoSection showAnswer: boolean - onLongPressStart: () => void - onLongPressEnd: () => void + onLongPressStart?: () => void + onLongPressEnd?: () => void } function PracticeList({ items, answeredItems, + mode, currentPracticeId, + currentSection, + section, showAnswer, onLongPressStart, onLongPressEnd, }: PracticeListProps) { if (items.length === 0) return null + return (
    {items.map((item) => { const isAnswered = answeredItems.has(item.id) - const isCurrent = item.id === currentPracticeId + const isCurrent = mode === 'standard' && item.id === currentPracticeId + const isCurrentSection = mode === 'proficient' && currentSection === section + const shouldRevealAnswer = mode === 'standard' + ? isCurrent && showAnswer + : isCurrentSection && showAnswer const data = item.originalData const hasDetail = data?.detail && data.detail.length > 0 @@ -799,16 +945,18 @@ function PracticeList({ ? 'border-2 border-indigo-400 dark:border-indigo-500 bg-indigo-50/60 dark:bg-indigo-900/20' : isAnswered ? 'border border-transparent' - : 'border border-dashed border-gray-300 dark:border-gray-600' + : isCurrentSection + ? 'border border-indigo-200 dark:border-indigo-800/60 bg-indigo-50/30 dark:bg-indigo-900/10' + : 'border border-dashed border-gray-300 dark:border-gray-600' }`} - onPointerDown={isCurrent ? (e) => { e.preventDefault(); onLongPressStart() } : undefined} + onPointerDown={isCurrent ? (event) => { event.preventDefault(); onLongPressStart?.() } : undefined} onPointerUp={isCurrent ? onLongPressEnd : undefined} onPointerLeave={isCurrent ? onLongPressEnd : undefined} onPointerCancel={isCurrent ? onLongPressEnd : undefined} > {isAnswered ? (
    -
    +
    {item.name}
    @@ -833,15 +981,24 @@ function PracticeList({
    )}
    - ) : isCurrent && showAnswer ? ( -
    + ) : shouldRevealAnswer ? ( +
    {item.name} 答案
    - ) : isCurrent ? ( - {'_'.repeat(item.name.length)} ) : ( - {'_'.repeat(item.name.length)} +
    + + {'_'.repeat(item.name.length)} + + {mode === 'proficient' && isCurrentSection && ( + 待输入 + )} +
    )}
    diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 941ef6a..f8fe912 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,9 +1,26 @@ import { motion } from 'framer-motion' -import { Sun, Moon, Palette } from 'lucide-react' -import { useAppStore } from '@/stores/useAppStore' +import { Sun, Moon, Palette, BrainCircuit } from 'lucide-react' +import { useAppStore, type PracticeMode } from '@/stores/useAppStore' + +const PRACTICE_MODE_OPTIONS: Array<{ + value: PracticeMode + label: string + description: string +}> = [ + { + value: 'standard', + label: '标准模式', + description: '按当前顺序逐项、逐字符输入,适合建立记忆路径。', + }, + { + value: 'proficient', + label: '熟练模式', + description: '单输入框自动核对,组内可乱序输入,适合冲刺强化。', + }, +] export function SettingsPage() { - const { darkMode, setDarkMode } = useAppStore() + const { darkMode, setDarkMode, practiceMode, setPracticeMode } = useAppStore() return (
    @@ -68,6 +85,64 @@ export function SettingsPage() {
    + {/* 练习设置 */} + +
    + +

    练习设置

    +
    +
    +
    + +

    + 标准模式适合逐步记忆,熟练模式适合在输入、工具、输出三个分组中自由回忆。 +

    +
    +
    + {PRACTICE_MODE_OPTIONS.map((option) => { + const isSelected = practiceMode === option.value + return ( + + ) + })} +
    +
    +
    + {/* 联系方式 */} void @@ -15,6 +18,7 @@ interface AppState { setDarkMode: (dark: boolean) => void setSearchQuery: (query: string) => void setMatrixFullScreen: (fullScreen: boolean) => void + setPracticeMode: (mode: PracticeMode) => void } export const useAppStore = create()( @@ -25,6 +29,7 @@ export const useAppStore = create()( darkMode: false, searchQuery: '', matrixFullScreen: false, + practiceMode: 'standard', // 操作方法 toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), @@ -33,6 +38,7 @@ export const useAppStore = create()( setDarkMode: (dark) => set({ darkMode: dark }), setSearchQuery: (query) => set({ searchQuery: query }), setMatrixFullScreen: (fullScreen) => set({ matrixFullScreen: fullScreen }), + setPracticeMode: (mode) => set({ practiceMode: mode }), }), { name: 'ittoview-app-storage', @@ -40,6 +46,7 @@ export const useAppStore = create()( sidebarOpen: state.sidebarOpen, darkMode: state.darkMode, matrixFullScreen: state.matrixFullScreen, + practiceMode: state.practiceMode, // searchQuery 不持久化到 localStorage,刷新后重置 }), } diff --git a/src/utils/practice.ts b/src/utils/practice.ts index 5848671..cf2f7f6 100644 --- a/src/utils/practice.ts +++ b/src/utils/practice.ts @@ -16,6 +16,32 @@ export interface CellInfo { order: number // 全局顺序 } +const FULL_WIDTH_TO_HALF_WIDTH_MAP: Record = { + '(': '(', + ')': ')', + '【': '[', + '】': ']', + '{': '{', + '}': '}', + '《': '<', + '》': '>', + '“': '"', + '”': '"', + '‘': "'", + '’': "'", + ':': ':', + ';': ';', + ',': ',', + '。': '.', + '、': ',', + '!': '!', + '?': '?', + '—': '-', + '-': '-', + '·': '', + ' ': ' ', +} + /** * 答案标准化函数 * @param str 原始字符串 @@ -25,10 +51,12 @@ export function normalizeAnswer( str: string, isKnowledgeArea: boolean = false ): string { - let normalized = str - .replace(/\s+/g, '') // 去除空格 - .toLowerCase() // 转小写(如有英文) - .replace(/[,。、;:""''()【】]/g, '') // 去除中文标点 + let normalized = Array.from(str) + .map((char) => FULL_WIDTH_TO_HALF_WIDTH_MAP[char] ?? char) + .join('') + .replace(/\s+/g, '') + .toLowerCase() + .replace(/[,:;.!?"'()\[\]{}<>,。、;:?!“”‘’()【】《》]/g, '') // 只对知识领域去除"项目"前缀 if (isKnowledgeArea) { @@ -56,8 +84,8 @@ export function generateCellSequence(): CellInfo[] { id: `ka-${ka.id}`, type: 'knowledge-area', knowledgeAreaId: ka.id, - answer: ka.name, // 保留完整名称 "项目整合管理" - normalizedAnswer: normalizeAnswer(ka.name, true), // 标准化时去除"项目" + answer: ka.name, + normalizedAnswer: normalizeAnswer(ka.name, true), order: order++, }) @@ -75,7 +103,7 @@ export function generateCellSequence(): CellInfo[] { processGroupId: pg.id, processId: p.id, answer: p.name, - normalizedAnswer: normalizeAnswer(p.name, false), // 过程不去除"项目" + normalizedAnswer: normalizeAnswer(p.name, false), order: order++, }) }) @@ -108,7 +136,6 @@ export function announceToScreenReader(message: string): void { const liveRegion = document.getElementById('aria-live-region') if (liveRegion) { liveRegion.textContent = message - // 清空,以便下次通告 setTimeout(() => { liveRegion.textContent = '' }, 1000)