diff --git a/src/App.tsx b/src/App.tsx index 243a423..ed32875 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { SettingsPage } from './pages/SettingsPage' import ProcessPracticePage from './pages/ProcessPracticePage' import PrinciplesPage from './pages/PrinciplesPage' import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage' +import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage' function App() { return ( @@ -20,6 +21,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/KnowledgeAreasTailoringPage.tsx b/src/pages/KnowledgeAreasTailoringPage.tsx new file mode 100644 index 0000000..d6c6c01 --- /dev/null +++ b/src/pages/KnowledgeAreasTailoringPage.tsx @@ -0,0 +1,792 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import clsx from 'clsx' +import { motion } from 'framer-motion' +import { Lightbulb, Maximize2, Minimize2, RotateCcw } from 'lucide-react' +import { knowledgeAreas } from '@/data' +import { InputArea } from '@/components/practice/InputArea' +import { CelebrationAnimation } from '@/components/practice/CelebrationAnimation' +import { useLongPress } from '@/hooks/useLongPress' +import { announceToScreenReader, normalizeAnswer } from '@/utils/practice' +import { + generateTailoringPracticeItems, + type TailoringPracticeItem, +} from '@/utils/tailoringPractice' +import { useAppStore } from '@/stores/useAppStore' + +type CharStatus = 'pending' | 'correct' | 'error' + +const STORAGE_KEY = 'knowledge-areas-tailoring-practice-progress' + +interface AnswerOverlayState { + itemId: string + answer: string + expiresAt: number +} + +interface FactorTitleCellProps { + item: TailoringPracticeItem + isPracticeMode: boolean + isAnswered: boolean + isCurrent: boolean + showAnswer: string | null + onLongPress: (itemId: string) => void + onLongPressEnd: () => void + onClick: (itemId: string) => void +} + +function FactorTitleCell({ + item, + isPracticeMode, + isAnswered, + isCurrent, + showAnswer, + onLongPress, + onLongPressEnd, + onClick, +}: FactorTitleCellProps) { + const longPressHandlers = useLongPress(item.id, { + onLongPress, + onLongPressEnd, + }) + + if (!isPracticeMode) { + return ( +
+ + {item.factorIndex + 1} + + {item.title} +
+ ) + } + + return ( + + ) +} + +export default function KnowledgeAreasTailoringPage() { + const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) + const [practiceItems] = useState(() => generateTailoringPracticeItems()) + const practiceItemMap = useMemo( + () => new Map(practiceItems.map((item) => [item.id, item])), + [practiceItems] + ) + const sortedKnowledgeAreas = useMemo( + () => [...knowledgeAreas].sort((a, b) => a.order - b.order), + [] + ) + + const groupedKnowledgeAreas = useMemo( + () => + sortedKnowledgeAreas.map((knowledgeArea) => ({ + ...knowledgeArea, + items: practiceItems.filter((item) => item.knowledgeAreaId === knowledgeArea.id), + })), + [practiceItems, sortedKnowledgeAreas] + ) + + const loadProgress = useCallback(() => { + const validIds = new Set(practiceItems.map((item) => item.id)) + + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (!saved) { + return { + answeredItems: new Map(), + currentItemId: practiceItems[0]?.id ?? null, + } + } + + const parsed = JSON.parse(saved) + const answeredEntries = Array.isArray(parsed.answeredItems) + ? parsed.answeredItems.filter( + (entry: unknown) => + Array.isArray(entry) && entry[1] === true && typeof entry[0] === 'string' && validIds.has(entry[0]) + ) + : [] + + return { + answeredItems: new Map(answeredEntries), + currentItemId: + typeof parsed.currentItemId === 'string' && validIds.has(parsed.currentItemId) + ? parsed.currentItemId + : practiceItems[0]?.id ?? null, + } + } catch (error) { + console.error('加载裁剪因素练习进度失败:', error) + return { + answeredItems: new Map(), + currentItemId: practiceItems[0]?.id ?? null, + } + } + }, [practiceItems]) + + const [isPracticeMode, setIsPracticeMode] = useState(false) + const [isFullScreen, setIsFullScreen] = useState(false) + const [answeredItems, setAnsweredItems] = useState>( + () => loadProgress().answeredItems + ) + const [currentItemId, setCurrentItemId] = useState( + () => loadProgress().currentItemId + ) + const [userInput, setUserInput] = useState([]) + const [charStatuses, setCharStatuses] = useState([]) + const [isComposing, setIsComposing] = useState(false) + const isComposingRef = useRef(false) + const latestInputRef = useRef([]) + const [lastErrorTimestamp, setLastErrorTimestamp] = useState(null) + const [showAnswerForItem, setShowAnswerForItem] = useState(null) + const [inputLocked, setInputLocked] = useState(false) + const [showCelebration, setShowCelebration] = useState(false) + + const currentItem = currentItemId ? practiceItemMap.get(currentItemId) ?? null : null + const answeredCount = answeredItems.size + const totalCount = practiceItems.length + + useEffect(() => { + latestInputRef.current = userInput + }, [userInput]) + + useEffect(() => { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + answeredItems: Array.from(answeredItems.entries()), + currentItemId, + }) + ) + } catch (error) { + console.error('保存裁剪因素练习进度失败:', error) + } + }, [answeredItems, currentItemId]) + + useEffect(() => { + if (!isPracticeMode) { + setShowAnswerForItem(null) + setInputLocked(false) + setIsComposing(false) + isComposingRef.current = false + return + } + + if (!currentItem && practiceItems[0]) { + setCurrentItemId(practiceItems[0].id) + return + } + + if (!currentItem) return + + setUserInput(new Array(currentItem.title.length).fill('')) + setCharStatuses(new Array(currentItem.title.length).fill('pending')) + }, [currentItem, isPracticeMode, practiceItems]) + + const restoreFocus = useCallback(() => { + setTimeout(() => { + const inputs = document.querySelectorAll('.practice-input-area input') + const firstEmptyInput = Array.from(inputs).find( + (input) => !(input as HTMLInputElement).value + ) as HTMLInputElement | undefined + + if (firstEmptyInput) { + firstEmptyInput.focus() + } else { + ;(inputs[0] as HTMLInputElement | undefined)?.focus() + } + }, 100) + }, []) + + const switchToItem = useCallback((item: TailoringPracticeItem) => { + setCurrentItemId(item.id) + setUserInput(new Array(item.title.length).fill('')) + setCharStatuses(new Array(item.title.length).fill('pending')) + setLastErrorTimestamp(null) + + requestAnimationFrame(() => { + const element = document.querySelector(`[data-factor-id="${item.id}"]`) as HTMLElement | null + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }) + + setTimeout(() => { + const firstInput = document.querySelector('.practice-input-area input') as HTMLInputElement | null + firstInput?.focus() + }, 150) + }, []) + + const moveToNextItem = useCallback(() => { + const currentIndex = practiceItems.findIndex((item) => item.id === currentItemId) + if (currentIndex === -1 || currentIndex === practiceItems.length - 1) return + switchToItem(practiceItems[currentIndex + 1]) + }, [currentItemId, practiceItems, switchToItem]) + + const moveToPrevItem = useCallback(() => { + const currentIndex = practiceItems.findIndex((item) => item.id === currentItemId) + if (currentIndex <= 0) return + switchToItem(practiceItems[currentIndex - 1]) + }, [currentItemId, practiceItems, switchToItem]) + + const validateInput = useCallback( + (input: string[]) => { + if (!currentItem || !currentItemId) return + + const originalAnswer = currentItem.title + const normalizedInput = normalizeAnswer(input.join(''), false) + const normalizedAnswer = currentItem.normalizedAnswer + const normalizeChar = (char: string) => normalizeAnswer(char, false) || char + + const nextStatuses = input.map((char, index) => { + if (!char) return 'pending' as CharStatus + const expectedChar = originalAnswer[index] || '' + if (!expectedChar) return 'error' as CharStatus + return normalizeChar(char) === normalizeChar(expectedChar) + ? ('correct' as CharStatus) + : ('error' as CharStatus) + }) + setCharStatuses(nextStatuses) + + const isComplete = input.every((char) => char !== '') && input.length === originalAnswer.length + const isCorrect = isComplete && normalizedInput === normalizedAnswer + + if (isCorrect) { + const alreadyAnswered = answeredItems.get(currentItemId) === true + const nextAnsweredCount = alreadyAnswered ? answeredItems.size : answeredItems.size + 1 + + setAnsweredItems((prev) => new Map(prev).set(currentItemId, true)) + + if (nextAnsweredCount === practiceItems.length) { + setTimeout(() => { + setShowCelebration(true) + }, 300) + return + } + + setTimeout(() => { + moveToNextItem() + }, 300) + } else if (isComplete) { + setLastErrorTimestamp(Date.now()) + } + }, + [answeredItems, currentItem, currentItemId, moveToNextItem, practiceItems.length] + ) + + const handleInputChange = useCallback( + (newInput: string[]) => { + latestInputRef.current = newInput + setUserInput(newInput) + + if (isComposingRef.current) return + + validateInput(newInput) + }, + [validateInput] + ) + + const handleCompositionStart = useCallback((_index: number) => { + isComposingRef.current = true + setIsComposing(true) + }, []) + + const handleCompositionEnd = useCallback( + (index: number, value: string) => { + isComposingRef.current = false + setIsComposing(false) + + requestAnimationFrame(() => { + const currentInput = latestInputRef.current + const nextInput = [...currentInput] + + if (value) { + const chars = value.split('') + for (let i = 0; i < chars.length && index + i < nextInput.length; i += 1) { + nextInput[index + i] = chars[i] + } + } else { + nextInput[index] = '' + } + + latestInputRef.current = nextInput + setUserInput(nextInput) + validateInput(nextInput) + }) + }, + [validateInput] + ) + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault() + if (!currentItem) return + + const pastedText = e.clipboardData.getData('text') + const nextInput = new Array(currentItem.title.length).fill('') + const chars = pastedText.split('') + + for (let i = 0; i < Math.min(chars.length, currentItem.title.length); i += 1) { + nextInput[i] = chars[i] + } + + handleInputChange(nextInput) + }, + [currentItem, handleInputChange] + ) + + const handleLongPress = useCallback( + (itemId: string) => { + const item = practiceItemMap.get(itemId) + if (!item) return + + setShowAnswerForItem({ + itemId, + answer: item.title, + expiresAt: Date.now() + 3000, + }) + setInputLocked(true) + announceToScreenReader('答案已显示') + }, + [practiceItemMap] + ) + + const handleLongPressEnd = useCallback(() => { + if (!showAnswerForItem) return + + setShowAnswerForItem(null) + setInputLocked(false) + announceToScreenReader('答案已隐藏') + restoreFocus() + }, [restoreFocus, showAnswerForItem]) + + useEffect(() => { + if (!showAnswerForItem) return + + const remaining = showAnswerForItem.expiresAt - Date.now() + if (remaining <= 0) { + setShowAnswerForItem(null) + setInputLocked(false) + restoreFocus() + return + } + + const timer = setTimeout(() => { + setShowAnswerForItem(null) + setInputLocked(false) + announceToScreenReader('答案已自动隐藏') + restoreFocus() + }, remaining) + + return () => clearTimeout(timer) + }, [restoreFocus, showAnswerForItem]) + + const handleFactorClick = useCallback( + (itemId: string) => { + const item = practiceItemMap.get(itemId) + if (!item) return + switchToItem(item) + }, + [practiceItemMap, switchToItem] + ) + + const toggleFullScreen = useCallback(() => { + if (!isFullScreen) { + setSidebarOpen(false) + } + setIsFullScreen((prev) => !prev) + }, [isFullScreen, setSidebarOpen]) + + const handleResetProgress = useCallback(() => { + if (!window.confirm('确定要清除当前练习进度吗?')) return + + setAnsweredItems(new Map()) + setCurrentItemId(practiceItems[0]?.id ?? null) + setUserInput([]) + setCharStatuses([]) + setLastErrorTimestamp(null) + localStorage.removeItem(STORAGE_KEY) + announceToScreenReader('练习进度已重置') + }, [practiceItems]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const lowerKey = e.key.toLowerCase() + + if (e.ctrlKey && lowerKey === 'h') { + e.preventDefault() + if (isPracticeMode && currentItemId && !showAnswerForItem) { + handleLongPress(currentItemId) + } + return + } + + if (e.key === 'Escape') { + if (showAnswerForItem) { + handleLongPressEnd() + return + } + + if (isFullScreen) { + setIsFullScreen(false) + return + } + + if (isPracticeMode) { + setUserInput((prev) => new Array(prev.length).fill('')) + setCharStatuses((prev) => new Array(prev.length).fill('pending')) + } + return + } + + if (!isPracticeMode) return + + if (e.key === 'Tab') { + e.preventDefault() + if (e.shiftKey) { + moveToPrevItem() + } else { + moveToNextItem() + } + } + } + + const handleKeyUp = (e: KeyboardEvent) => { + const lowerKey = e.key.toLowerCase() + if ((lowerKey === 'control' || lowerKey === 'h') && showAnswerForItem) { + handleLongPressEnd() + } + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, [ + currentItemId, + handleLongPress, + handleLongPressEnd, + isFullScreen, + isPracticeMode, + moveToNextItem, + moveToPrevItem, + showAnswerForItem, + ]) + + const renderToolbar = (compact = false) => ( + <> +
+
+
+ + 10 个知识领域 · {totalCount} 项敏捷裁剪因素 +
+

+ 结合知识领域查看裁剪关注点,并在练习模式中完成因素标题记忆。 +

+
+ +
+ {isPracticeMode && ( + + )} + +
+ + +
+ + +
+
+ + {isPracticeMode && ( +
+
+ 练习进度:{answeredCount} / {totalCount} + {currentItem && ( + + 当前:{currentItem.knowledgeAreaName} · 第 {currentItem.factorIndex + 1} 项 + + )} +
+
+ +
+
+ )} + + ) + + return ( +
+ {isFullScreen && ( + + )} + + {!isFullScreen && ( +
+
+

知识领域敏捷裁剪因素

+

+ 以知识领域为主线查看裁剪关注点,并在同一页面完成练习。 +

+
+
+ )} + +
+ {renderToolbar(isFullScreen)} + +
+ + + + + + + + + + {groupedKnowledgeAreas.map((knowledgeArea) => { + if (knowledgeArea.items.length === 0) return null + + const isCurrentKnowledgeArea = currentItem?.knowledgeAreaId === knowledgeArea.id + + return knowledgeArea.items.map((item, itemIndex) => { + const isAnswered = answeredItems.get(item.id) === true + const isCurrent = isPracticeMode && currentItemId === item.id + const isShowingAnswer = showAnswerForItem?.itemId === item.id ? showAnswerForItem.answer : null + + return ( + + {itemIndex === 0 && ( + + )} + + + + + + ) + }) + })} + +
+ 知识领域 + + 敏捷裁剪因素 + + 说明 +
+
+
+
+ {knowledgeArea.order} +
+
+

+ {knowledgeArea.name} +

+

+ {knowledgeArea.items.length} 项裁剪因素 +

+
+
+

+ {knowledgeArea.description} +

+ {isPracticeMode && isCurrentKnowledgeArea && ( +
+ 当前正在练习该知识领域中的第 {currentItem ? currentItem.factorIndex + 1 : 1} 项 +
+ )} +
+
+ + +
+ {item.description} +
+
+
+ + {isPracticeMode && currentItem && ( +
+
+
+
+ + +
+
+ +
+
+
+ {currentItem.knowledgeAreaName} +
+
+ 第 {currentItem.factorIndex + 1} 项 · 长按标题或 Ctrl+H 查看答案 · Tab 切换 +
+
+

+ {currentItem.description} +

+
+
+
+ )} +
+ +
+ + {showCelebration && setShowCelebration(false)} />} +
+ ) +} diff --git a/src/utils/tailoringPractice.ts b/src/utils/tailoringPractice.ts new file mode 100644 index 0000000..2391489 --- /dev/null +++ b/src/utils/tailoringPractice.ts @@ -0,0 +1,41 @@ +import { knowledgeAreas } from '@/data' +import { normalizeAnswer } from '@/utils/practice' + +export interface TailoringPracticeItem { + id: string + knowledgeAreaId: string + knowledgeAreaName: string + knowledgeAreaColor: string + knowledgeAreaOrder: number + factorIndex: number + title: string + description: string + normalizedAnswer: string +} + +/** + * 生成敏捷裁剪因素练习序列 + * 顺序:按知识领域排序,再按该知识领域中的裁剪因素原始顺序展开 + */ +export function generateTailoringPracticeItems(): TailoringPracticeItem[] { + const sortedKnowledgeAreas = [...knowledgeAreas].sort((a, b) => a.order - b.order) + const items: TailoringPracticeItem[] = [] + + sortedKnowledgeAreas.forEach((knowledgeArea) => { + knowledgeArea.tailoringFactors?.forEach((factor, index) => { + items.push({ + id: `${knowledgeArea.id}-factor-${index + 1}`, + knowledgeAreaId: knowledgeArea.id, + knowledgeAreaName: knowledgeArea.name, + knowledgeAreaColor: knowledgeArea.color, + knowledgeAreaOrder: knowledgeArea.order, + factorIndex: index, + title: factor.title, + description: factor.description, + normalizedAnswer: normalizeAnswer(factor.title, false), + }) + }) + }) + + return items +}