feat: 新增知识领域敏捷裁剪因素页面
This commit is contained in:
@@ -12,6 +12,7 @@ import { SettingsPage } from './pages/SettingsPage'
|
|||||||
import ProcessPracticePage from './pages/ProcessPracticePage'
|
import ProcessPracticePage from './pages/ProcessPracticePage'
|
||||||
import PrinciplesPage from './pages/PrinciplesPage'
|
import PrinciplesPage from './pages/PrinciplesPage'
|
||||||
import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage'
|
import { PerformanceDomainsPage } from './pages/PerformanceDomainsPage'
|
||||||
|
import KnowledgeAreasTailoringPage from './pages/KnowledgeAreasTailoringPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -20,6 +21,7 @@ function App() {
|
|||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/knowledge-areas" element={<KnowledgeAreasPage />} />
|
<Route path="/knowledge-areas" element={<KnowledgeAreasPage />} />
|
||||||
<Route path="/knowledge-areas/:id" element={<KnowledgeAreasPage />} />
|
<Route path="/knowledge-areas/:id" element={<KnowledgeAreasPage />} />
|
||||||
|
<Route path="/knowledge-areas-tailoring" element={<KnowledgeAreasTailoringPage />} />
|
||||||
<Route path="/process-groups" element={<ProcessGroupsPage />} />
|
<Route path="/process-groups" element={<ProcessGroupsPage />} />
|
||||||
<Route path="/process-groups/:id" element={<ProcessGroupsPage />} />
|
<Route path="/process-groups/:id" element={<ProcessGroupsPage />} />
|
||||||
<Route path="/process/:id" element={<ProcessDetailPage />} />
|
<Route path="/process/:id" element={<ProcessDetailPage />} />
|
||||||
|
|||||||
792
src/pages/KnowledgeAreasTailoringPage.tsx
Normal file
792
src/pages/KnowledgeAreasTailoringPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-4 text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-amber-100 px-2 text-xs font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||||
|
{item.factorIndex + 1}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium leading-6">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClick(item.id)}
|
||||||
|
tabIndex={isCurrent ? 0 : -1}
|
||||||
|
aria-current={isCurrent ? 'step' : undefined}
|
||||||
|
aria-label={`裁剪因素:${item.title}`}
|
||||||
|
className={clsx(
|
||||||
|
'relative flex min-h-[72px] w-full items-center gap-3 px-4 py-4 text-left transition-all duration-200 focus:outline-none',
|
||||||
|
isCurrent
|
||||||
|
? 'bg-indigo-50 dark:bg-indigo-900/20 ring-2 ring-inset ring-indigo-500'
|
||||||
|
: 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/60',
|
||||||
|
!isAnswered && 'border-dashed'
|
||||||
|
)}
|
||||||
|
{...longPressHandlers}
|
||||||
|
>
|
||||||
|
<span className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-amber-100 px-2 text-xs font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||||
|
{item.factorIndex + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
{isAnswered ? (
|
||||||
|
<span className="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-100">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-6 items-center">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'block h-3 rounded-full bg-gray-200 dark:bg-gray-600',
|
||||||
|
isCurrent && 'bg-indigo-200 dark:bg-indigo-700/70'
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(Math.max(item.title.length, 4) * 0.8, 8)}rem` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{showAnswer && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/80 px-4">
|
||||||
|
<span className="text-center text-sm font-medium text-white">
|
||||||
|
{showAnswer}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnowledgeAreasTailoringPage() {
|
||||||
|
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
|
||||||
|
const [practiceItems] = useState<TailoringPracticeItem[]>(() => 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<string, boolean>(),
|
||||||
|
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<string, boolean>(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<string, boolean>(),
|
||||||
|
currentItemId: practiceItems[0]?.id ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [practiceItems])
|
||||||
|
|
||||||
|
const [isPracticeMode, setIsPracticeMode] = useState(false)
|
||||||
|
const [isFullScreen, setIsFullScreen] = useState(false)
|
||||||
|
const [answeredItems, setAnsweredItems] = useState<Map<string, boolean>>(
|
||||||
|
() => loadProgress().answeredItems
|
||||||
|
)
|
||||||
|
const [currentItemId, setCurrentItemId] = useState<string | null>(
|
||||||
|
() => loadProgress().currentItemId
|
||||||
|
)
|
||||||
|
const [userInput, setUserInput] = useState<string[]>([])
|
||||||
|
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
|
||||||
|
const [isComposing, setIsComposing] = useState(false)
|
||||||
|
const isComposingRef = useRef(false)
|
||||||
|
const latestInputRef = useRef<string[]>([])
|
||||||
|
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(null)
|
||||||
|
const [showAnswerForItem, setShowAnswerForItem] = useState<AnswerOverlayState | null>(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) => (
|
||||||
|
<>
|
||||||
|
<div className={clsx('flex items-center justify-between gap-4', compact ? 'px-4 py-3' : 'px-5 py-4')}>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
<Lightbulb className="h-4 w-4 text-amber-500" />
|
||||||
|
<span>10 个知识领域 · {totalCount} 项敏捷裁剪因素</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
结合知识领域查看裁剪关注点,并在练习模式中完成因素标题记忆。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isPracticeMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetProgress}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors hover:text-red-600 dark:border-gray-700 dark:text-gray-300 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
清除进度
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex rounded-lg bg-gray-100 p-0.5 dark:bg-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPracticeMode(false)}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
!isPracticeMode
|
||||||
|
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-600 dark:text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPracticeMode(true)}
|
||||||
|
className={clsx(
|
||||||
|
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
isPracticeMode
|
||||||
|
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-600 dark:text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
练习
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleFullScreen}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{isFullScreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||||
|
{isFullScreen ? '退出全屏' : '全屏查看'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPracticeMode && (
|
||||||
|
<div className={clsx('border-t border-gray-100 dark:border-gray-700', compact ? 'px-4 py-3' : 'px-5 py-3')}>
|
||||||
|
<div className="mb-2 flex items-center justify-between text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span>练习进度:{answeredCount} / {totalCount}</span>
|
||||||
|
{currentItem && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
当前:{currentItem.knowledgeAreaName} · 第 {currentItem.factorIndex + 1} 项
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<motion.div
|
||||||
|
className="h-2 rounded-full bg-indigo-500"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${(answeredCount / totalCount) * 100}%` }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(isFullScreen ? 'fixed inset-0 z-50 bg-gray-50 dark:bg-gray-900' : 'space-y-6')}>
|
||||||
|
{isFullScreen && (
|
||||||
|
<style>{`
|
||||||
|
.tailoring-no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tailoring-no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isFullScreen && (
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">知识领域敏捷裁剪因素</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
以知识领域为主线查看裁剪关注点,并在同一页面完成练习。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'overflow-hidden border border-gray-100 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800',
|
||||||
|
isFullScreen ? 'flex h-full flex-col border-0 rounded-none shadow-none' : 'rounded-2xl min-h-[calc(100vh-12rem)] flex flex-col'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderToolbar(isFullScreen)}
|
||||||
|
|
||||||
|
<div className={clsx('flex-1 overflow-auto', isFullScreen && 'tailoring-no-scrollbar')}>
|
||||||
|
<table className="min-w-[1120px] w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky left-0 top-0 z-20 min-w-[220px] border border-gray-200 bg-gray-100 px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
知识领域
|
||||||
|
</th>
|
||||||
|
<th className="sticky top-0 z-10 min-w-[260px] border border-gray-200 bg-gray-100 px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
敏捷裁剪因素
|
||||||
|
</th>
|
||||||
|
<th className="sticky top-0 z-10 min-w-[640px] border border-gray-200 bg-gray-100 px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
说明
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
data-factor-id={item.id}
|
||||||
|
className={clsx(isCurrent && 'bg-indigo-50/40 dark:bg-indigo-900/10')}
|
||||||
|
>
|
||||||
|
{itemIndex === 0 && (
|
||||||
|
<td
|
||||||
|
rowSpan={knowledgeArea.items.length}
|
||||||
|
className="sticky left-0 z-10 border border-gray-200 px-4 py-4 align-top dark:border-gray-700"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${knowledgeArea.color}16`,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: knowledgeArea.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-lg text-sm font-bold text-white"
|
||||||
|
style={{ backgroundColor: knowledgeArea.color }}
|
||||||
|
>
|
||||||
|
{knowledgeArea.order}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{knowledgeArea.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{knowledgeArea.items.length} 项裁剪因素
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||||
|
{knowledgeArea.description}
|
||||||
|
</p>
|
||||||
|
{isPracticeMode && isCurrentKnowledgeArea && (
|
||||||
|
<div className="rounded-lg bg-white/70 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900/20 dark:text-gray-300">
|
||||||
|
当前正在练习该知识领域中的第 {currentItem ? currentItem.factorIndex + 1 : 1} 项
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<td className="border border-gray-200 p-0 align-top dark:border-gray-700">
|
||||||
|
<FactorTitleCell
|
||||||
|
item={item}
|
||||||
|
isPracticeMode={isPracticeMode}
|
||||||
|
isAnswered={isAnswered}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
showAnswer={isShowingAnswer}
|
||||||
|
onLongPress={handleLongPress}
|
||||||
|
onLongPressEnd={handleLongPressEnd}
|
||||||
|
onClick={handleFactorClick}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="border border-gray-200 align-top dark:border-gray-700">
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'min-h-[72px] px-4 py-4 text-sm leading-7 text-gray-700 dark:text-gray-200',
|
||||||
|
isCurrent && 'border-l-2 border-indigo-500 bg-indigo-50/50 dark:bg-indigo-900/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPracticeMode && currentItem && (
|
||||||
|
<div className="border-t border-gray-200 bg-white/90 backdrop-blur-md dark:border-gray-700 dark:bg-gray-800/90">
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="border-b border-gray-200/70 pb-4 dark:border-gray-700/70">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<InputArea
|
||||||
|
userInput={userInput}
|
||||||
|
charStatuses={charStatuses}
|
||||||
|
isComposing={isComposing}
|
||||||
|
inputLocked={inputLocked}
|
||||||
|
lastErrorTimestamp={lastErrorTimestamp}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
onCompositionStart={handleCompositionStart}
|
||||||
|
onCompositionEnd={handleCompositionEnd}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => currentItemId && handleLongPress(currentItemId)}
|
||||||
|
className="p-2 text-gray-500 transition-colors hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
|
||||||
|
title="查看答案(长按表格中的因素标题也可以)"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 py-4 md:grid-cols-[220px,1fr] md:items-start">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{currentItem.knowledgeAreaName}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
第 {currentItem.factorIndex + 1} 项 · 长按标题或 Ctrl+H 查看答案 · Tab 切换
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-7 text-gray-700 dark:text-gray-200">
|
||||||
|
{currentItem.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="aria-live-region" className="sr-only" aria-live="polite" aria-atomic="true" />
|
||||||
|
|
||||||
|
{showCelebration && <CelebrationAnimation onComplete={() => setShowCelebration(false)} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
src/utils/tailoringPractice.ts
Normal file
41
src/utils/tailoringPractice.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user