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

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-02 07:35:10 +00:00

758 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { getProcessDetail, processes } from '@/data'
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() {
const { id } = useParams()
const location = useLocation()
const navigate = useNavigate()
const processDetail = id ? getProcessDetail(id) : null
// ITTO 显示/隐藏状态管理
const [visible, setVisible] = useState<Record<IttoSection, boolean>>(() => {
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,
tools: typeof parsed.tools === 'boolean' ? parsed.tools : true,
outputs: typeof parsed.outputs === 'boolean' ? parsed.outputs : true,
}
} catch {
return defaultVisible
}
})
// 持久化状态
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] }))
}
const allVisible = Object.values(visible).every(Boolean)
const toggleAll = () => {
const newState = !allVisible
setVisible({ inputs: newState, tools: newState, outputs: newState })
}
// ── 练习条目 ──────────────────────────────────────────────────────────────
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 (
<div className="flex flex-col items-center justify-center py-20">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4"></h2>
<Link to="/knowledge-areas" className="text-indigo-600 dark:text-indigo-400 hover:underline">
</Link>
</div>
)
}
const ka = processDetail.knowledgeArea
const pg = processDetail.processGroup
const purpose = (processDetail as any).purpose
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 ${isPracticeMode ? 'pb-36' : ''}`}>
{/* 返回按钮 + 面包屑 */}
<div className="flex items-center gap-3 text-sm">
{fromMatrix && (
<button
onClick={() => navigate('/process-matrix')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors font-medium"
>
<LayoutGrid size={14} />
</button>
)}
<nav className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
<Link to="/knowledge-areas" className="hover:text-indigo-600 dark:hover:text-indigo-400"></Link>
<span>/</span>
{ka && (
<>
<Link to={`/knowledge-areas/${ka.id}`} className="hover:text-indigo-600 dark:hover:text-indigo-400">{ka.name}</Link>
<span>/</span>
</>
)}
<span className="text-gray-900 dark:text-white">{processDetail.name}</span>
</nav>
</div>
{/* 过程标题 - 更紧凑 */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-4 bg-white dark:bg-gray-800 shadow-sm border border-gray-100 dark:border-gray-700"
>
<div className="flex items-center gap-3">
<div
className="flex h-12 w-12 items-center justify-center rounded-lg text-white font-bold text-lg shrink-0"
style={{ backgroundColor: ka?.color || '#6366F1' }}
>
{processDetail.code}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold text-gray-900 dark:text-white truncate">{processDetail.name}</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{processDetail.nameEn}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{ka && (
<span className="px-2.5 py-1 rounded-full text-xs font-medium text-white" style={{ backgroundColor: ka.color }}>
{ka.name}
</span>
)}
{pg && (
<span className="px-2.5 py-1 rounded-full text-xs font-medium text-white" style={{ backgroundColor: pg.color }}>
{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>
{/* 主要作用 */}
{purpose && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="bg-indigo-50 dark:bg-indigo-900/20 rounded-xl p-4 border border-indigo-100 dark:border-indigo-800"
>
<div className="flex items-center gap-2 mb-2">
<Info size={16} className="text-indigo-600 dark:text-indigo-400 shrink-0" />
<h3 className="text-sm font-semibold text-indigo-900 dark:text-indigo-100"></h3>
</div>
<p className="text-sm text-indigo-800 dark:text-indigo-200 leading-relaxed">{purpose}</p>
</motion.div>
)}
{/* 全局控制按钮 */}
{!isPracticeMode && (
<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"
>
{allVisible ? <EyeOff size={16} /> : <Eye size={16} />}
<span>{allVisible ? '全部隐藏' : '全部显示'}</span>
</button>
</motion.div>
)}
{/* ITTO表格 - 更紧凑 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid lg:grid-cols-3 gap-4"
>
{/* 输入 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-blue-50 dark:bg-blue-900/30 border-b border-blue-100 dark:border-blue-800">
<div className="flex items-center gap-2">
<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>
{!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: isPracticeMode ? 2000 : visible.inputs ? 2000 : 0,
opacity: isPracticeMode ? 1 : visible.inputs ? 1 : 0,
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{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
return (
<li key={inputDetail.id} className="px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="font-medium text-gray-900 dark:text-white text-sm">{inputDetail.name || inputDetail.id}</div>
{inputDetail.nameEn && <div className="text-xs text-gray-500 dark:text-gray-400">{inputDetail.nameEn}</div>}
{hasDetail && (
<div className="mt-1.5 text-xs text-gray-600 dark:text-gray-400">
{inputDetail.detail.map((item: any, idx: number) => (
<span key={item.id || idx}>
{item.label}
{idx < inputDetail.detail.length - 1 && '、'}
</span>
))}
</div>
)}
{inputDetail.note && (
<div className="mt-1.5 text-xs text-gray-500 dark:text-gray-400 italic">
💡 {inputDetail.note}
</div>
)}
</li>
)
})}
</ul>
) : (
<div className="h-0" />
)}
</motion.div>
</div>
{/* 工具与技术 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-100 dark:border-amber-800">
<div className="flex items-center gap-2">
<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>
{!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: isPracticeMode ? 2000 : visible.tools ? 2000 : 0,
opacity: isPracticeMode ? 1 : visible.tools ? 1 : 0,
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{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
return (
<li key={toolDetail.id} className="px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="font-medium text-gray-900 dark:text-white text-sm">{toolDetail.name || toolDetail.id}</div>
{toolDetail.nameEn && <div className="text-xs text-gray-500 dark:text-gray-400">{toolDetail.nameEn}</div>}
{hasDetail && (
<div className="mt-1.5 text-xs text-gray-600 dark:text-gray-400">
{toolDetail.detail.map((item: any, idx: number) => (
<span key={item.id || idx}>
{item.label}
{idx < toolDetail.detail.length - 1 && '、'}
</span>
))}
</div>
)}
{toolDetail.note && (
<div className="mt-1.5 text-xs text-gray-500 dark:text-gray-400 italic">
💡 {toolDetail.note}
</div>
)}
</li>
)
})}
</ul>
) : (
<div className="h-0" />
)}
</motion.div>
</div>
{/* 输出 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-emerald-50 dark:bg-emerald-900/30 border-b border-emerald-100 dark:border-emerald-800">
<div className="flex items-center gap-2">
<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>
{!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: isPracticeMode ? 2000 : visible.outputs ? 2000 : 0,
opacity: isPracticeMode ? 1 : visible.outputs ? 1 : 0,
}}
initial={false}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{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
return (
<li key={outputDetail.id} className="px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="font-medium text-gray-900 dark:text-white text-sm">{outputDetail.name || outputDetail.id}</div>
{outputDetail.nameEn && <div className="text-xs text-gray-500 dark:text-gray-400">{outputDetail.nameEn}</div>}
{hasDetail && (
<div className="mt-1.5 text-xs text-gray-600 dark:text-gray-400">
{outputDetail.detail.map((item: any, idx: number) => (
<span key={item.id || idx}>
{item.label}
{idx < outputDetail.detail.length - 1 && '、'}
</span>
))}
</div>
)}
{outputDetail.note && (
<div className="mt-1.5 text-xs text-gray-500 dark:text-gray-400 italic">
💡 {outputDetail.note}
</div>
)}
</li>
)
})}
</ul>
) : (
<div className="h-0" />
)}
</motion.div>
</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 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-700"
>
{prevProcess ? (
<Link
to={`/process/${prevProcess.id}`}
state={location.state}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors text-sm"
>
<ArrowLeft size={16} />
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">{prevProcess.code} {prevProcess.name}</div>
</div>
</Link>
) : <div />}
{nextProcess ? (
<Link
to={`/process/${nextProcess.id}`}
state={location.state}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors text-right text-sm"
>
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">{nextProcess.code} {nextProcess.name}</div>
</div>
<ArrowRight size={16} />
</Link>
) : <div />}
</motion.div>
</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>
)
}