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