From 6879a6bd54c1e65c7d205870d39c78bf1246ed87 Mon Sep 17 00:00:00 2001 From: ittoview Date: Mon, 2 Mar 2026 07:35:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(=E8=BF=87=E7=A8=8B=E8=AF=A6=E6=83=85):=20?= =?UTF-8?q?=E5=86=85=E5=B5=8C=20ITTO=20=E7=BB=83=E4=B9=A0=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标题区右侧新增"开始练习"/"退出练习"按钮 - 练习模式下 ITTO 三列强制展开,隐藏显示/隐藏控制按钮 - 列表项渲染三态:已答对(✓)、当前作答(高亮虚线)、未作答(下划线遮盖) - 页面底部 sticky 输入区,复用 InputArea 组件,支持中文输入法 - 按住"按住看答案"按钮或列表项长按显示答案,松开隐藏 - 答题顺序:输入→工具→输出,答对自动跳下一项,全部完成后退出 - 切换过程(URL 变化)时自动退出练习,避免定时器跨过程触发 via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- src/pages/ProcessDetailPage.tsx | 482 ++++++++++++++++++++++++++++---- 1 file changed, 423 insertions(+), 59 deletions(-) diff --git a/src/pages/ProcessDetailPage.tsx b/src/pages/ProcessDetailPage.tsx index dff6752..42a560f 100644 --- a/src/pages/ProcessDetailPage.tsx +++ b/src/pages/ProcessDetailPage.tsx @@ -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(() => { + 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>(new Set()) + const [currentPracticeId, setCurrentPracticeId] = useState(null) + const [userInput, setUserInput] = useState([]) + const [charStatuses, setCharStatuses] = useState([]) + const [isComposing, setIsComposing] = useState(false) + const isComposingRef = useRef(false) + const latestInputRef = useRef([]) + const [inputLocked, setInputLocked] = useState(false) + const [lastErrorTimestamp, setLastErrorTimestamp] = useState(null) + const [showAnswer, setShowAnswer] = useState(false) + const longPressTimerRef = useRef(null) + const autoAdvanceTimerRef = useRef(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((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 ( -
+
{/* 返回按钮 + 面包屑 */}
{fromMatrix && ( @@ -127,6 +342,18 @@ export function ProcessDetailPage() { {pg.name} )} +
@@ -148,23 +375,25 @@ export function ProcessDetailPage() { )} {/* 全局控制按钮 */} - - - + + + )} {/* ITTO表格 - 更紧凑 */}

输入 ({processDetail.inputs.length})

- + {!isPracticeMode && ( + + )} - {visible.inputs ? ( + {isPracticeMode ? ( + it.section === 'inputs')} + answeredItems={answeredItems} + currentPracticeId={currentPracticeId} + showAnswer={showAnswer} + onLongPressStart={handleLongPressStart} + onLongPressEnd={handleLongPressEnd} + /> + ) : visible.inputs ? (
    {processDetail.inputDetails?.map((inputDetail: any) => { const hasDetail = inputDetail.detail && inputDetail.detail.length > 0 @@ -240,27 +480,38 @@ export function ProcessDetailPage() {

    工具与技术 ({processDetail.tools.length})

    - + {!isPracticeMode && ( + + )} - {visible.tools ? ( + {isPracticeMode ? ( + it.section === 'tools')} + answeredItems={answeredItems} + currentPracticeId={currentPracticeId} + showAnswer={showAnswer} + onLongPressStart={handleLongPressStart} + onLongPressEnd={handleLongPressEnd} + /> + ) : visible.tools ? (
      {processDetail.toolDetails?.map((toolDetail: any) => { const hasDetail = toolDetail.detail && toolDetail.detail.length > 0 @@ -300,27 +551,38 @@ export function ProcessDetailPage() {

      输出 ({processDetail.outputs.length})

      - + {!isPracticeMode && ( + + )} - {visible.outputs ? ( + {isPracticeMode ? ( + it.section === 'outputs')} + answeredItems={answeredItems} + currentPracticeId={currentPracticeId} + showAnswer={showAnswer} + onLongPressStart={handleLongPressStart} + onLongPressEnd={handleLongPressEnd} + /> + ) : visible.outputs ? (
        {processDetail.outputDetails?.map((outputDetail: any) => { const hasDetail = outputDetail.detail && outputDetail.detail.length > 0 @@ -354,6 +616,45 @@ export function ProcessDetailPage() { + {/* 练习模式底部输入区域 */} + {isPracticeMode && currentPracticeItem && ( +
        +
        + + 第 {currentPracticeIndex + 1} 项 / 共 {practiceItems.length} 项 + + {showAnswer && ( + + 答案:{currentPracticeItem.name} + + )} +
        +
        + + +
        +
        + )} + {/* 前后导航 - 更紧凑 */} ) } + +// ── 练习列表子组件 ────────────────────────────────────────────────────────── +interface PracticeListProps { + items: PracticeItem[] + answeredItems: Set + 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 ( +
          + {items.map((item) => { + const isAnswered = answeredItems.has(item.id) + const isCurrent = item.id === currentPracticeId + + return ( +
        • +
          { e.preventDefault(); onLongPressStart() } : undefined} + onPointerUp={isCurrent ? onLongPressEnd : undefined} + onPointerLeave={isCurrent ? onLongPressEnd : undefined} + onPointerCancel={isCurrent ? onLongPressEnd : undefined} + > + {isAnswered ? ( +
          + {item.name} + +
          + ) : isCurrent && showAnswer ? ( +
          + {item.name} + 答案 +
          + ) : isCurrent ? ( + {'_'.repeat(item.name.length)} + ) : ( + {'_'.repeat(item.name.length)} + )} +
          +
        • + ) + })} +
        + ) +}