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": [
|
"changelogEntries": [
|
||||||
|
{
|
||||||
|
"id": "2026-03-23-process-detail-proficient-mode",
|
||||||
|
"date": "2026-03-23",
|
||||||
|
"type": "feat",
|
||||||
|
"title": "过程详情练习新增熟练模式,并优化答案展示后的输入恢复与界面层次",
|
||||||
|
"scope": "过程详情"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "2026-03-19-quality-tool-update",
|
"id": "2026-03-19-quality-tool-update",
|
||||||
"date": "2026-03-19",
|
"date": "2026-03-19",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,26 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Sun, Moon, Palette } from 'lucide-react'
|
import { Sun, Moon, Palette, BrainCircuit } from 'lucide-react'
|
||||||
import { useAppStore } from '@/stores/useAppStore'
|
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() {
|
export function SettingsPage() {
|
||||||
const { darkMode, setDarkMode } = useAppStore()
|
const { darkMode, setDarkMode, practiceMode, setPracticeMode } = useAppStore()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -68,6 +85,64 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
export type PracticeMode = 'standard' | 'proficient'
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// UI状态
|
// UI状态
|
||||||
sidebarOpen: boolean
|
sidebarOpen: boolean
|
||||||
darkMode: boolean
|
darkMode: boolean
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
matrixFullScreen: boolean
|
matrixFullScreen: boolean
|
||||||
|
practiceMode: PracticeMode
|
||||||
|
|
||||||
// 操作
|
// 操作
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void
|
||||||
@@ -15,6 +18,7 @@ interface AppState {
|
|||||||
setDarkMode: (dark: boolean) => void
|
setDarkMode: (dark: boolean) => void
|
||||||
setSearchQuery: (query: string) => void
|
setSearchQuery: (query: string) => void
|
||||||
setMatrixFullScreen: (fullScreen: boolean) => void
|
setMatrixFullScreen: (fullScreen: boolean) => void
|
||||||
|
setPracticeMode: (mode: PracticeMode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>()(
|
export const useAppStore = create<AppState>()(
|
||||||
@@ -25,6 +29,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
darkMode: false,
|
darkMode: false,
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
matrixFullScreen: false,
|
matrixFullScreen: false,
|
||||||
|
practiceMode: 'standard',
|
||||||
|
|
||||||
// 操作方法
|
// 操作方法
|
||||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
@@ -33,6 +38,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
setDarkMode: (dark) => set({ darkMode: dark }),
|
setDarkMode: (dark) => set({ darkMode: dark }),
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
setMatrixFullScreen: (fullScreen) => set({ matrixFullScreen: fullScreen }),
|
setMatrixFullScreen: (fullScreen) => set({ matrixFullScreen: fullScreen }),
|
||||||
|
setPracticeMode: (mode) => set({ practiceMode: mode }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ittoview-app-storage',
|
name: 'ittoview-app-storage',
|
||||||
@@ -40,6 +46,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
sidebarOpen: state.sidebarOpen,
|
sidebarOpen: state.sidebarOpen,
|
||||||
darkMode: state.darkMode,
|
darkMode: state.darkMode,
|
||||||
matrixFullScreen: state.matrixFullScreen,
|
matrixFullScreen: state.matrixFullScreen,
|
||||||
|
practiceMode: state.practiceMode,
|
||||||
// searchQuery 不持久化到 localStorage,刷新后重置
|
// searchQuery 不持久化到 localStorage,刷新后重置
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,32 @@ export interface CellInfo {
|
|||||||
order: number // 全局顺序
|
order: number // 全局顺序
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FULL_WIDTH_TO_HALF_WIDTH_MAP: Record<string, string> = {
|
||||||
|
'(': '(',
|
||||||
|
')': ')',
|
||||||
|
'【': '[',
|
||||||
|
'】': ']',
|
||||||
|
'{': '{',
|
||||||
|
'}': '}',
|
||||||
|
'《': '<',
|
||||||
|
'》': '>',
|
||||||
|
'“': '"',
|
||||||
|
'”': '"',
|
||||||
|
'‘': "'",
|
||||||
|
'’': "'",
|
||||||
|
':': ':',
|
||||||
|
';': ';',
|
||||||
|
',': ',',
|
||||||
|
'。': '.',
|
||||||
|
'、': ',',
|
||||||
|
'!': '!',
|
||||||
|
'?': '?',
|
||||||
|
'—': '-',
|
||||||
|
'-': '-',
|
||||||
|
'·': '',
|
||||||
|
' ': ' ',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 答案标准化函数
|
* 答案标准化函数
|
||||||
* @param str 原始字符串
|
* @param str 原始字符串
|
||||||
@@ -25,10 +51,12 @@ export function normalizeAnswer(
|
|||||||
str: string,
|
str: string,
|
||||||
isKnowledgeArea: boolean = false
|
isKnowledgeArea: boolean = false
|
||||||
): string {
|
): string {
|
||||||
let normalized = str
|
let normalized = Array.from(str)
|
||||||
.replace(/\s+/g, '') // 去除空格
|
.map((char) => FULL_WIDTH_TO_HALF_WIDTH_MAP[char] ?? char)
|
||||||
.toLowerCase() // 转小写(如有英文)
|
.join('')
|
||||||
.replace(/[,。、;:""''()【】]/g, '') // 去除中文标点
|
.replace(/\s+/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[,:;.!?"'()\[\]{}<>,。、;:?!“”‘’()【】《》]/g, '')
|
||||||
|
|
||||||
// 只对知识领域去除"项目"前缀
|
// 只对知识领域去除"项目"前缀
|
||||||
if (isKnowledgeArea) {
|
if (isKnowledgeArea) {
|
||||||
@@ -56,8 +84,8 @@ export function generateCellSequence(): CellInfo[] {
|
|||||||
id: `ka-${ka.id}`,
|
id: `ka-${ka.id}`,
|
||||||
type: 'knowledge-area',
|
type: 'knowledge-area',
|
||||||
knowledgeAreaId: ka.id,
|
knowledgeAreaId: ka.id,
|
||||||
answer: ka.name, // 保留完整名称 "项目整合管理"
|
answer: ka.name,
|
||||||
normalizedAnswer: normalizeAnswer(ka.name, true), // 标准化时去除"项目"
|
normalizedAnswer: normalizeAnswer(ka.name, true),
|
||||||
order: order++,
|
order: order++,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,7 +103,7 @@ export function generateCellSequence(): CellInfo[] {
|
|||||||
processGroupId: pg.id,
|
processGroupId: pg.id,
|
||||||
processId: p.id,
|
processId: p.id,
|
||||||
answer: p.name,
|
answer: p.name,
|
||||||
normalizedAnswer: normalizeAnswer(p.name, false), // 过程不去除"项目"
|
normalizedAnswer: normalizeAnswer(p.name, false),
|
||||||
order: order++,
|
order: order++,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -108,7 +136,6 @@ export function announceToScreenReader(message: string): void {
|
|||||||
const liveRegion = document.getElementById('aria-live-region')
|
const liveRegion = document.getElementById('aria-live-region')
|
||||||
if (liveRegion) {
|
if (liveRegion) {
|
||||||
liveRegion.textContent = message
|
liveRegion.textContent = message
|
||||||
// 清空,以便下次通告
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
liveRegion.textContent = ''
|
liveRegion.textContent = ''
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|||||||
Reference in New Issue
Block a user