feat(过程详情): 新增ITTO熟练模式并优化练习交互
This commit is contained in:
88
src/components/practice/ProficientInputArea.tsx
Normal file
88
src/components/practice/ProficientInputArea.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
interface ProficientInputAreaProps {
|
||||
value: string
|
||||
isComposing: boolean
|
||||
inputLocked: boolean
|
||||
hasError: boolean
|
||||
statusText?: string | null
|
||||
onChange: (value: string) => void
|
||||
onCompositionStart: () => void
|
||||
onCompositionEnd: (value: string) => void
|
||||
}
|
||||
|
||||
export function ProficientInputArea({
|
||||
value,
|
||||
isComposing,
|
||||
inputLocked,
|
||||
hasError,
|
||||
statusText,
|
||||
onChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
}: ProficientInputAreaProps) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputLocked) return
|
||||
inputRef.current?.focus()
|
||||
}, [inputLocked, value])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-3 practice-input-area">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={(e) => onCompositionEnd(e.currentTarget.value)}
|
||||
disabled={inputLocked}
|
||||
placeholder="输入当前分组中的一个完整条目,停顿 800ms 自动核对"
|
||||
className={clsx(
|
||||
'w-full rounded-xl border px-4 py-3 text-base md:text-lg',
|
||||
'bg-white dark:bg-gray-800/80 text-gray-900 dark:text-gray-100',
|
||||
'transition-all duration-200 focus:outline-none focus:ring-2',
|
||||
isComposing && 'border-gray-300 dark:border-gray-600 opacity-80',
|
||||
!isComposing && !hasError && 'border-gray-300 dark:border-gray-600 focus:ring-indigo-400 focus:border-indigo-400',
|
||||
!isComposing && hasError && 'border-red-400 focus:ring-red-400 focus:border-red-400',
|
||||
inputLocked && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
|
||||
<div className="min-h-6 text-sm">
|
||||
<AnimatePresence mode="wait">
|
||||
{inputLocked ? (
|
||||
<motion.div
|
||||
key="locked"
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
答案显示中,输入已锁定
|
||||
</motion.div>
|
||||
) : statusText ? (
|
||||
<motion.div
|
||||
key={statusText}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
className={clsx(
|
||||
hasError ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{statusText}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"changelogEntries": [
|
||||
{
|
||||
"id": "2026-03-23-process-detail-proficient-mode",
|
||||
"date": "2026-03-23",
|
||||
"type": "feat",
|
||||
"title": "过程详情练习新增熟练模式,并优化答案展示后的输入恢复与界面层次",
|
||||
"scope": "过程详情"
|
||||
},
|
||||
{
|
||||
"id": "2026-03-19-quality-tool-update",
|
||||
"date": "2026-03-19",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,26 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Sun, Moon, Palette } from 'lucide-react'
|
||||
import { useAppStore } from '@/stores/useAppStore'
|
||||
import { Sun, Moon, Palette, BrainCircuit } from 'lucide-react'
|
||||
import { useAppStore, type PracticeMode } from '@/stores/useAppStore'
|
||||
|
||||
const PRACTICE_MODE_OPTIONS: Array<{
|
||||
value: PracticeMode
|
||||
label: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
value: 'standard',
|
||||
label: '标准模式',
|
||||
description: '按当前顺序逐项、逐字符输入,适合建立记忆路径。',
|
||||
},
|
||||
{
|
||||
value: 'proficient',
|
||||
label: '熟练模式',
|
||||
description: '单输入框自动核对,组内可乱序输入,适合冲刺强化。',
|
||||
},
|
||||
]
|
||||
|
||||
export function SettingsPage() {
|
||||
const { darkMode, setDarkMode } = useAppStore()
|
||||
const { darkMode, setDarkMode, practiceMode, setPracticeMode } = useAppStore()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -68,6 +85,64 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 练习设置 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
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 gap-3 px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<BrainCircuit size={20} className="text-indigo-600 dark:text-indigo-400" />
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">练习设置</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
过程详情页练习模式
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
标准模式适合逐步记忆,熟练模式适合在输入、工具、输出三个分组中自由回忆。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{PRACTICE_MODE_OPTIONS.map((option) => {
|
||||
const isSelected = practiceMode === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setPracticeMode(option.value)}
|
||||
className={`text-left rounded-xl border-2 p-4 transition-all ${
|
||||
isSelected
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className={`font-semibold ${
|
||||
isSelected
|
||||
? 'text-indigo-700 dark:text-indigo-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
|
||||
当前使用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 联系方式 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type PracticeMode = 'standard' | 'proficient'
|
||||
|
||||
interface AppState {
|
||||
// UI状态
|
||||
sidebarOpen: boolean
|
||||
darkMode: boolean
|
||||
searchQuery: string
|
||||
matrixFullScreen: boolean
|
||||
practiceMode: PracticeMode
|
||||
|
||||
// 操作
|
||||
toggleSidebar: () => void
|
||||
@@ -15,6 +18,7 @@ interface AppState {
|
||||
setDarkMode: (dark: boolean) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
setMatrixFullScreen: (fullScreen: boolean) => void
|
||||
setPracticeMode: (mode: PracticeMode) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
@@ -25,6 +29,7 @@ export const useAppStore = create<AppState>()(
|
||||
darkMode: false,
|
||||
searchQuery: '',
|
||||
matrixFullScreen: false,
|
||||
practiceMode: 'standard',
|
||||
|
||||
// 操作方法
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
@@ -33,6 +38,7 @@ export const useAppStore = create<AppState>()(
|
||||
setDarkMode: (dark) => set({ darkMode: dark }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setMatrixFullScreen: (fullScreen) => set({ matrixFullScreen: fullScreen }),
|
||||
setPracticeMode: (mode) => set({ practiceMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'ittoview-app-storage',
|
||||
@@ -40,6 +46,7 @@ export const useAppStore = create<AppState>()(
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
darkMode: state.darkMode,
|
||||
matrixFullScreen: state.matrixFullScreen,
|
||||
practiceMode: state.practiceMode,
|
||||
// searchQuery 不持久化到 localStorage,刷新后重置
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -16,6 +16,32 @@ export interface CellInfo {
|
||||
order: number // 全局顺序
|
||||
}
|
||||
|
||||
const FULL_WIDTH_TO_HALF_WIDTH_MAP: Record<string, string> = {
|
||||
'(': '(',
|
||||
')': ')',
|
||||
'【': '[',
|
||||
'】': ']',
|
||||
'{': '{',
|
||||
'}': '}',
|
||||
'《': '<',
|
||||
'》': '>',
|
||||
'“': '"',
|
||||
'”': '"',
|
||||
'‘': "'",
|
||||
'’': "'",
|
||||
':': ':',
|
||||
';': ';',
|
||||
',': ',',
|
||||
'。': '.',
|
||||
'、': ',',
|
||||
'!': '!',
|
||||
'?': '?',
|
||||
'—': '-',
|
||||
'-': '-',
|
||||
'·': '',
|
||||
' ': ' ',
|
||||
}
|
||||
|
||||
/**
|
||||
* 答案标准化函数
|
||||
* @param str 原始字符串
|
||||
@@ -25,10 +51,12 @@ export function normalizeAnswer(
|
||||
str: string,
|
||||
isKnowledgeArea: boolean = false
|
||||
): string {
|
||||
let normalized = str
|
||||
.replace(/\s+/g, '') // 去除空格
|
||||
.toLowerCase() // 转小写(如有英文)
|
||||
.replace(/[,。、;:""''()【】]/g, '') // 去除中文标点
|
||||
let normalized = Array.from(str)
|
||||
.map((char) => FULL_WIDTH_TO_HALF_WIDTH_MAP[char] ?? char)
|
||||
.join('')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[,:;.!?"'()\[\]{}<>,。、;:?!“”‘’()【】《》]/g, '')
|
||||
|
||||
// 只对知识领域去除"项目"前缀
|
||||
if (isKnowledgeArea) {
|
||||
@@ -56,8 +84,8 @@ export function generateCellSequence(): CellInfo[] {
|
||||
id: `ka-${ka.id}`,
|
||||
type: 'knowledge-area',
|
||||
knowledgeAreaId: ka.id,
|
||||
answer: ka.name, // 保留完整名称 "项目整合管理"
|
||||
normalizedAnswer: normalizeAnswer(ka.name, true), // 标准化时去除"项目"
|
||||
answer: ka.name,
|
||||
normalizedAnswer: normalizeAnswer(ka.name, true),
|
||||
order: order++,
|
||||
})
|
||||
|
||||
@@ -75,7 +103,7 @@ export function generateCellSequence(): CellInfo[] {
|
||||
processGroupId: pg.id,
|
||||
processId: p.id,
|
||||
answer: p.name,
|
||||
normalizedAnswer: normalizeAnswer(p.name, false), // 过程不去除"项目"
|
||||
normalizedAnswer: normalizeAnswer(p.name, false),
|
||||
order: order++,
|
||||
})
|
||||
})
|
||||
@@ -108,7 +136,6 @@ export function announceToScreenReader(message: string): void {
|
||||
const liveRegion = document.getElementById('aria-live-region')
|
||||
if (liveRegion) {
|
||||
liveRegion.textContent = message
|
||||
// 清空,以便下次通告
|
||||
setTimeout(() => {
|
||||
liveRegion.textContent = ''
|
||||
}, 1000)
|
||||
|
||||
Reference in New Issue
Block a user