feat(过程详情): 新增ITTO熟练模式并优化练习交互

This commit is contained in:
ittoview
2026-03-23 15:24:16 +00:00
parent b09e203a90
commit d9bbd28da5
6 changed files with 742 additions and 381 deletions

View 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>
)
}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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刷新后重置
}),
}

View File

@@ -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)