feat(过程详情): 内嵌 ITTO 练习模式

- 标题区右侧新增"开始练习"/"退出练习"按钮
- 练习模式下 ITTO 三列强制展开,隐藏显示/隐藏控制按钮
- 列表项渲染三态:已答对(✓)、当前作答(高亮虚线)、未作答(下划线遮盖)
- 页面底部 sticky 输入区,复用 InputArea 组件,支持中文输入法
- 按住"按住看答案"按钮或列表项长按显示答案,松开隐藏
- 答题顺序:输入→工具→输出,答对自动跳下一项,全部完成后退出
- 切换过程(URL 变化)时自动退出练习,避免定时器跨过程触发

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
ittoview
2026-03-02 07:35:10 +00:00
parent 71c611edf3
commit 6879a6bd54

View File

@@ -1,10 +1,21 @@
import { useParams, Link, useLocation, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowLeft, ArrowRight, FileText, Wrench, FileOutput, LayoutGrid, Eye, EyeOff, Info } 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 } from 'react'
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { InputArea } from '@/components/practice/InputArea'
import { normalizeAnswer } from '@/utils/practice'
type IttoSection = 'inputs' | 'tools' | 'outputs'
type CharStatus = 'pending' | 'correct' | 'error'
interface PracticeItem {
id: string
section: IttoSection
name: string
normalizedAnswer: string
}
const STORAGE_KEY = 'ittoview:process-detail:itto-visibility'
export function ProcessDetailPage() {
@@ -52,7 +63,210 @@ export function ProcessDetailPage() {
setVisible({ inputs: newState, tools: newState, outputs: newState })
}
const fromMatrix = location.state?.from === 'matrix'
// ── 练习条目 ──────────────────────────────────────────────────────────────
const practiceItems = useMemo<PracticeItem[]>(() => {
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 {
id: `${section}-${d?.id || i}`,
section,
name,
normalizedAnswer: normalizeAnswer(name, false),
}
})
}
return [
...buildItems(processDetail.inputDetails, 'inputs'),
...buildItems(processDetail.toolDetails, 'tools'),
...buildItems(processDetail.outputDetails, 'outputs'),
]
}, [processDetail])
// ── 练习模式状态 ──────────────────────────────────────────────────────────
const [isPracticeMode, setIsPracticeMode] = useState(false)
const [answeredItems, setAnsweredItems] = useState<Set<string>>(new Set())
const [currentPracticeId, setCurrentPracticeId] = useState<string | null>(null)
const [userInput, setUserInput] = useState<string[]>([])
const [charStatuses, setCharStatuses] = useState<CharStatus[]>([])
const [isComposing, setIsComposing] = useState(false)
const isComposingRef = useRef(false)
const latestInputRef = useRef<string[]>([])
const [inputLocked, setInputLocked] = useState(false)
const [lastErrorTimestamp, setLastErrorTimestamp] = useState<number | null>(null)
const [showAnswer, setShowAnswer] = useState(false)
const longPressTimerRef = useRef<number | null>(null)
const autoAdvanceTimerRef = useRef<number | null>(null)
const currentPracticeItem = useMemo(
() => practiceItems.find((item) => item.id === currentPracticeId) ?? null,
[practiceItems, currentPracticeId]
)
const currentPracticeIndex = practiceItems.findIndex((item) => item.id === currentPracticeId)
// 清理定时器
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)
autoAdvanceTimerRef.current = null
}
}, [])
useEffect(() => {
return () => { clearLongPressTimer(); clearAutoAdvanceTimer() }
}, [clearLongPressTimer, clearAutoAdvanceTimer])
// 切换到某个练习项时重置输入状态
useEffect(() => {
if (!isPracticeMode || !currentPracticeItem) return
setUserInput(new Array(currentPracticeItem.name.length).fill(''))
setCharStatuses(new Array(currentPracticeItem.name.length).fill('pending'))
setLastErrorTimestamp(null)
setShowAnswer(false)
setInputLocked(false)
}, [isPracticeMode, currentPracticeItem])
useEffect(() => { latestInputRef.current = userInput }, [userInput])
// 切换过程时自动退出练习(避免定时器跨过程触发)
useEffect(() => {
exitPractice()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
// ── 验证逻辑 ──────────────────────────────────────────────────────────────
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 newStatuses = input.map<CharStatus>((char, i) => {
if (!char) return 'pending'
const expected = originalAnswer[i] ?? ''
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
if (isCorrect) {
setAnsweredItems((prev) => new Set(prev).add(currentPracticeItem.id))
clearAutoAdvanceTimer()
if (currentPracticeIndex === practiceItems.length - 1) {
autoAdvanceTimerRef.current = window.setTimeout(() => exitPractice(), 500)
} else {
autoAdvanceTimerRef.current = window.setTimeout(() => {
setCurrentPracticeId(practiceItems[currentPracticeIndex + 1].id)
}, 300)
}
} else if (isComplete) {
setLastErrorTimestamp(Date.now())
}
},
[currentPracticeItem, currentPracticeIndex, practiceItems, clearAutoAdvanceTimer]
)
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 cur = latestInputRef.current
const newInput = [...cur]
if (value) {
value.split('').forEach((ch, i) => {
if (index + i < newInput.length) newInput[index + i] = ch
})
} else {
newInput[index] = ''
}
latestInputRef.current = newInput
setUserInput(newInput)
validateInput(newInput)
})
},
[validateInput]
)
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
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 })
handleInputChange(newInput)
},
[currentPracticeItem, handleInputChange]
)
// ── 长按显示答案 ──────────────────────────────────────────────────────────
const handleLongPressStart = useCallback(() => {
clearLongPressTimer()
longPressTimerRef.current = window.setTimeout(() => {
setShowAnswer(true)
setInputLocked(true)
}, 300)
}, [clearLongPressTimer])
const handleLongPressEnd = useCallback(() => {
clearLongPressTimer()
setShowAnswer(false)
setInputLocked(false)
}, [clearLongPressTimer])
// ── 开始 / 退出练习 ───────────────────────────────────────────────────────
const startPractice = useCallback(() => {
if (practiceItems.length === 0) return
clearAutoAdvanceTimer()
setIsPracticeMode(true)
setAnsweredItems(new Set())
setCurrentPracticeId(practiceItems[0].id)
}, [practiceItems, clearAutoAdvanceTimer])
const exitPractice = useCallback(() => {
clearAutoAdvanceTimer()
clearLongPressTimer()
setIsPracticeMode(false)
setAnsweredItems(new Set())
setCurrentPracticeId(null)
setUserInput([])
setCharStatuses([])
setIsComposing(false)
isComposingRef.current = false
setLastErrorTimestamp(null)
setShowAnswer(false)
setInputLocked(false)
}, [clearAutoAdvanceTimer, clearLongPressTimer])
if (!processDetail) {
return (
@@ -72,9 +286,10 @@ export function ProcessDetailPage() {
const currentIndex = processes.findIndex(p => p.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'
return (
<div className="space-y-4">
<div className={`space-y-4 ${isPracticeMode ? 'pb-36' : ''}`}>
{/* 返回按钮 + 面包屑 */}
<div className="flex items-center gap-3 text-sm">
{fromMatrix && (
@@ -127,6 +342,18 @@ export function ProcessDetailPage() {
{pg.name}
</span>
)}
<button
type="button"
onClick={isPracticeMode ? exitPractice : startPractice}
disabled={practiceItems.length === 0}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
isPracticeMode
? 'bg-red-50 text-red-600 hover:bg-red-100 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50'
: 'bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50'
}`}
>
{isPracticeMode ? '退出练习' : '开始练习'}
</button>
</div>
</div>
</motion.div>
@@ -148,23 +375,25 @@ export function ProcessDetailPage() {
)}
{/* 全局控制按钮 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.08 }}
className="flex justify-end"
>
<button
type="button"
onClick={toggleAll}
aria-label={allVisible ? '隐藏所有 ITTO 内容' : '显示所有 ITTO 内容'}
aria-pressed={allVisible}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors text-sm font-medium"
{!isPracticeMode && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.08 }}
className="flex justify-end"
>
{allVisible ? <EyeOff size={16} /> : <Eye size={16} />}
<span>{allVisible ? '全部隐藏' : '全部显示'}</span>
</button>
</motion.div>
<button
type="button"
onClick={toggleAll}
aria-label={allVisible ? '隐藏所有 ITTO 内容' : '显示所有 ITTO 内容'}
aria-pressed={allVisible}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors text-sm font-medium"
>
{allVisible ? <EyeOff size={16} /> : <Eye size={16} />}
<span>{allVisible ? '全部隐藏' : '全部显示'}</span>
</button>
</motion.div>
)}
{/* ITTO表格 - 更紧凑 */}
<motion.div
@@ -180,27 +409,38 @@ export function ProcessDetailPage() {
<FileText size={16} className="text-blue-600 dark:text-blue-400" />
<h3 className="font-semibold text-blue-900 dark:text-blue-100 text-sm"> ({processDetail.inputs.length})</h3>
</div>
<button
type="button"
onClick={() => toggleSection('inputs')}
aria-label={visible.inputs ? '隐藏输入' : '显示输入'}
aria-pressed={visible.inputs}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{visible.inputs ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.inputs ? '隐藏' : '显示'}</span>
</button>
{!isPracticeMode && (
<button
type="button"
onClick={() => toggleSection('inputs')}
aria-label={visible.inputs ? '隐藏输入' : '显示输入'}
aria-pressed={visible.inputs}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
{visible.inputs ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.inputs ? '隐藏' : '显示'}</span>
</button>
)}
</div>
<motion.div
animate={{
maxHeight: visible.inputs ? 2000 : 0,
opacity: visible.inputs ? 1 : 0
maxHeight: isPracticeMode ? 2000 : visible.inputs ? 2000 : 0,
opacity: isPracticeMode ? 1 : visible.inputs ? 1 : 0,
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{visible.inputs ? (
{isPracticeMode ? (
<PracticeList
items={practiceItems.filter((it) => it.section === 'inputs')}
answeredItems={answeredItems}
currentPracticeId={currentPracticeId}
showAnswer={showAnswer}
onLongPressStart={handleLongPressStart}
onLongPressEnd={handleLongPressEnd}
/>
) : visible.inputs ? (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{processDetail.inputDetails?.map((inputDetail: any) => {
const hasDetail = inputDetail.detail && inputDetail.detail.length > 0
@@ -240,27 +480,38 @@ export function ProcessDetailPage() {
<Wrench size={16} className="text-amber-600 dark:text-amber-400" />
<h3 className="font-semibold text-amber-900 dark:text-amber-100 text-sm"> ({processDetail.tools.length})</h3>
</div>
<button
type="button"
onClick={() => toggleSection('tools')}
aria-label={visible.tools ? '隐藏工具与技术' : '显示工具与技术'}
aria-pressed={visible.tools}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors"
>
{visible.tools ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.tools ? '隐藏' : '显示'}</span>
</button>
{!isPracticeMode && (
<button
type="button"
onClick={() => toggleSection('tools')}
aria-label={visible.tools ? '隐藏工具与技术' : '显示工具与技术'}
aria-pressed={visible.tools}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors"
>
{visible.tools ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.tools ? '隐藏' : '显示'}</span>
</button>
)}
</div>
<motion.div
animate={{
maxHeight: visible.tools ? 2000 : 0,
opacity: visible.tools ? 1 : 0
maxHeight: isPracticeMode ? 2000 : visible.tools ? 2000 : 0,
opacity: isPracticeMode ? 1 : visible.tools ? 1 : 0,
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{visible.tools ? (
{isPracticeMode ? (
<PracticeList
items={practiceItems.filter((it) => it.section === 'tools')}
answeredItems={answeredItems}
currentPracticeId={currentPracticeId}
showAnswer={showAnswer}
onLongPressStart={handleLongPressStart}
onLongPressEnd={handleLongPressEnd}
/>
) : visible.tools ? (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{processDetail.toolDetails?.map((toolDetail: any) => {
const hasDetail = toolDetail.detail && toolDetail.detail.length > 0
@@ -300,27 +551,38 @@ export function ProcessDetailPage() {
<FileOutput size={16} className="text-emerald-600 dark:text-emerald-400" />
<h3 className="font-semibold text-emerald-900 dark:text-emerald-100 text-sm"> ({processDetail.outputs.length})</h3>
</div>
<button
type="button"
onClick={() => toggleSection('outputs')}
aria-label={visible.outputs ? '隐藏输出' : '显示输出'}
aria-pressed={visible.outputs}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 transition-colors"
>
{visible.outputs ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.outputs ? '隐藏' : '显示'}</span>
</button>
{!isPracticeMode && (
<button
type="button"
onClick={() => toggleSection('outputs')}
aria-label={visible.outputs ? '隐藏输出' : '显示输出'}
aria-pressed={visible.outputs}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 transition-colors"
>
{visible.outputs ? <EyeOff size={14} /> : <Eye size={14} />}
<span>{visible.outputs ? '隐藏' : '显示'}</span>
</button>
)}
</div>
<motion.div
animate={{
maxHeight: visible.outputs ? 2000 : 0,
opacity: visible.outputs ? 1 : 0
maxHeight: isPracticeMode ? 2000 : visible.outputs ? 2000 : 0,
opacity: isPracticeMode ? 1 : visible.outputs ? 1 : 0,
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{visible.outputs ? (
{isPracticeMode ? (
<PracticeList
items={practiceItems.filter((it) => it.section === 'outputs')}
answeredItems={answeredItems}
currentPracticeId={currentPracticeId}
showAnswer={showAnswer}
onLongPressStart={handleLongPressStart}
onLongPressEnd={handleLongPressEnd}
/>
) : visible.outputs ? (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{processDetail.outputDetails?.map((outputDetail: any) => {
const hasDetail = outputDetail.detail && outputDetail.detail.length > 0
@@ -354,6 +616,45 @@ export function ProcessDetailPage() {
</div>
</motion.div>
{/* 练习模式底部输入区域 */}
{isPracticeMode && currentPracticeItem && (
<div className="sticky bottom-0 z-10 bg-white/80 dark:bg-gray-800/80 backdrop-blur-md border-t border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPracticeIndex + 1} / {practiceItems.length}
</span>
{showAnswer && (
<span className="text-xs font-medium text-indigo-600 dark:text-indigo-400">
{currentPracticeItem.name}
</span>
)}
</div>
<div className="flex items-center justify-center gap-4">
<InputArea
userInput={userInput}
charStatuses={charStatuses}
isComposing={isComposing}
inputLocked={inputLocked}
lastErrorTimestamp={lastErrorTimestamp}
onInputChange={handleInputChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handlePaste}
/>
<button
type="button"
onPointerDown={(e) => { e.preventDefault(); handleLongPressStart() }}
onPointerUp={handleLongPressEnd}
onPointerLeave={handleLongPressEnd}
onPointerCancel={handleLongPressEnd}
className="shrink-0 px-3 py-2 rounded-lg text-xs font-medium text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-600 hover:border-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition-colors select-none"
>
</button>
</div>
</div>
)}
{/* 前后导航 - 更紧凑 */}
<motion.div
initial={{ opacity: 0 }}
@@ -391,3 +692,66 @@ export function ProcessDetailPage() {
</div>
)
}
// ── 练习列表子组件 ──────────────────────────────────────────────────────────
interface PracticeListProps {
items: PracticeItem[]
answeredItems: Set<string>
currentPracticeId: string | null
showAnswer: boolean
onLongPressStart: () => void
onLongPressEnd: () => void
}
function PracticeList({
items,
answeredItems,
currentPracticeId,
showAnswer,
onLongPressStart,
onLongPressEnd,
}: PracticeListProps) {
if (items.length === 0) return null
return (
<ul className="divide-y divide-gray-100 dark:divide-gray-700">
{items.map((item) => {
const isAnswered = answeredItems.has(item.id)
const isCurrent = item.id === currentPracticeId
return (
<li key={item.id} className="px-3 py-2">
<div
className={`rounded-md px-2.5 py-1.5 text-sm transition-colors ${
isCurrent
? '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'
}`}
onPointerDown={isCurrent ? (e) => { e.preventDefault(); onLongPressStart() } : undefined}
onPointerUp={isCurrent ? onLongPressEnd : undefined}
onPointerLeave={isCurrent ? onLongPressEnd : undefined}
onPointerCancel={isCurrent ? onLongPressEnd : undefined}
>
{isAnswered ? (
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900 dark:text-white">{item.name}</span>
<Check size={14} className="text-green-500 shrink-0" />
</div>
) : isCurrent && showAnswer ? (
<div className="flex items-center justify-between">
<span className="font-medium text-indigo-700 dark:text-indigo-300">{item.name}</span>
<span className="text-xs text-indigo-400"></span>
</div>
) : isCurrent ? (
<span className="font-medium text-indigo-400 dark:text-indigo-500 select-none">{'_'.repeat(item.name.length)}</span>
) : (
<span className="font-medium text-gray-300 dark:text-gray-600 select-none">{'_'.repeat(item.name.length)}</span>
)}
</div>
</li>
)
})}
</ul>
)
}