fix(练习): 重构布局和修复需求问题

- 修复知识领域显示完整名称(如"项目整合管理")
- 改用 table 布局,参考 process-matrix 样式
- 输入区域添加半透明背景(bg-white/80 + backdrop-blur-md)
- 辅助信息不再省略,显示完整内容
- 删除不需要的 KnowledgeAreaCell 组件
- 知识领域显示在左侧列,过程显示在单元格内

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
ittoview
2026-03-01 14:47:24 +00:00
parent 32172bec2d
commit 7edaebf0ab
5 changed files with 90 additions and 200 deletions

View File

@@ -23,22 +23,17 @@ export function HintInfo({ currentCell }: HintInfoProps) {
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
</h3>
<div className="space-y-1.5 text-xs">
{ka.tailoringFactors.slice(0, 2).map((factor, index) => (
<div className="space-y-2 text-xs">
{ka.tailoringFactors.map((factor, index) => (
<div key={index} className="flex gap-2">
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0">
{factor.title}
</span>
<span className="text-gray-600 dark:text-gray-400 line-clamp-1">
<span className="text-gray-600 dark:text-gray-400">
{factor.description}
</span>
</div>
))}
{ka.tailoringFactors.length > 2 && (
<div className="text-gray-500 dark:text-gray-500">
+{ka.tailoringFactors.length - 2} ...
</div>
)}
</div>
</div>
)
@@ -52,7 +47,7 @@ export function HintInfo({ currentCell }: HintInfoProps) {
</h3>
{purpose ? (
<p className="text-xs text-gray-700 dark:text-gray-300 line-clamp-2">
<p className="text-xs text-gray-700 dark:text-gray-300 leading-relaxed">
{purpose}
</p>
) : (

View File

@@ -1,87 +0,0 @@
import { motion } from 'framer-motion'
import clsx from 'clsx'
import type { KnowledgeArea } from '@/types/itto'
import type { CellInfo } from '@/utils/practice'
import { useLongPress } from '@/hooks/useLongPress'
interface KnowledgeAreaCellProps {
ka: KnowledgeArea
cellInfo?: CellInfo
isAnswered: boolean
isFocused: boolean
showAnswer?: string | null
onLongPress: (cellId: string) => void
onLongPressEnd: () => void
onClick: (cellId: string) => void
tabIndex: number
}
export function KnowledgeAreaCell({
ka,
cellInfo,
isAnswered,
isFocused,
showAnswer,
onLongPress,
onLongPressEnd,
onClick,
tabIndex,
}: KnowledgeAreaCellProps) {
const cellId = `ka-${ka.id}`
const longPressHandlers = useLongPress(cellId, {
onLongPress,
onLongPressEnd,
})
return (
<motion.div
data-cell-id={cellId}
className={clsx(
'relative rounded-lg p-2 transition-all duration-200 cursor-pointer',
'border-2 focus:outline-none',
isAnswered
? 'bg-white dark:bg-gray-800 border-solid border-gray-300 dark:border-gray-600'
: 'border-dashed border-gray-300 dark:border-gray-600',
isFocused && 'ring-2 ring-blue-500 border-blue-500',
!isAnswered && 'min-h-[40px]'
)}
onClick={() => onClick(cellId)}
tabIndex={tabIndex}
role="button"
aria-label={`知识领域:${ka.name}`}
aria-describedby="hint-info"
aria-current={isFocused ? 'true' : undefined}
{...longPressHandlers}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
{isAnswered && cellInfo && (
<div className="flex items-center gap-1.5">
<span
className="inline-block px-1.5 py-0.5 rounded text-white font-medium text-xs"
style={{ backgroundColor: ka.color }}
>
{ka.order}
</span>
<span className="text-gray-900 dark:text-gray-100 font-medium text-sm">
{cellInfo.answer}
</span>
</div>
)}
{/* 长按显示答案 */}
{showAnswer && (
<motion.div
className="absolute inset-0 flex items-center justify-center bg-black/80 rounded-lg z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<span className="text-white text-lg font-medium">{showAnswer}</span>
</motion.div>
)}
</motion.div>
)
}

View File

@@ -1,10 +1,8 @@
import { knowledgeAreas, processGroups } from '@/data'
import { getProcessesByKaAndPg, type CellInfo } from '@/utils/practice'
import { KnowledgeAreaCell } from './KnowledgeAreaCell'
import { getProcessesByKaAndPg } from '@/utils/practice'
import { ProcessCell } from './ProcessCell'
interface PracticeMatrixProps {
cellSequence: CellInfo[]
answeredCells: Map<string, boolean>
currentCellId: string | null
showAnswerForCell: { cellId: string; answer: string; expiresAt: number } | null
@@ -15,7 +13,6 @@ interface PracticeMatrixProps {
}
export function PracticeMatrix({
cellSequence,
answeredCells,
currentCellId,
showAnswerForCell,
@@ -28,104 +25,90 @@ export function PracticeMatrix({
const sortedPGs = [...processGroups].sort((a, b) => a.order - b.order)
return (
<div className="practice-matrix px-2">
{/* 表头 */}
<div
className="grid gap-2 mb-3 sticky bg-gray-50 dark:bg-gray-900 py-2 z-10"
style={{
gridTemplateColumns: 'repeat(5, 1fr)',
top: '56px', // 避开顶部进度条
}}
>
{sortedPGs.map((pg) => (
<div
key={pg.id}
className="text-center font-semibold text-xs text-gray-700 dark:text-gray-300"
>
{pg.name}
</div>
))}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[900px]">
{/* 表头:过程组 */}
<thead>
<tr>
<th className="sticky left-0 z-10 bg-gray-100 dark:bg-gray-800 p-2 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 min-w-[120px]">
/
</th>
{sortedPGs.map((pg) => (
<th
key={pg.id}
className="p-2 text-center text-xs font-semibold text-white border border-gray-200 dark:border-gray-700 min-w-[120px]"
style={{ backgroundColor: pg.color }}
>
{pg.name}
</th>
))}
</tr>
</thead>
{/* 矩阵内容 */}
{sortedKAs.map((ka) => {
const kaCellId = `ka-${ka.id}`
const kaCellInfo = cellSequence.find((c) => c.id === kaCellId)
{/* 表体:知识领域 × 过程 */}
<tbody>
{sortedKAs.map((ka) => {
return (
<tr key={ka.id}>
{/* 知识领域名称 */}
<td
className="sticky left-0 z-10 p-2 border border-gray-200 dark:border-gray-700 font-medium"
style={{
backgroundColor: `${ka.color}15`,
borderLeftWidth: 4,
borderLeftColor: ka.color,
}}
>
<div className="flex items-center gap-2">
<span className="font-bold text-xs" style={{ color: ka.color }}>
{ka.order}
</span>
<span className="text-xs text-gray-900 dark:text-white">
{ka.name}
</span>
</div>
</td>
return (
<div
key={ka.id}
className="ka-section mb-4"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(5, 1fr)',
gap: '0.5rem',
}}
>
{/* 知识领域格子横跨5列 */}
<div style={{ gridColumn: '1 / -1' }}>
<KnowledgeAreaCell
ka={ka}
cellInfo={kaCellInfo}
isAnswered={answeredCells.get(kaCellId) || false}
isFocused={currentCellId === kaCellId}
showAnswer={
showAnswerForCell?.cellId === kaCellId
? showAnswerForCell.answer
: null
}
onLongPress={onLongPress}
onLongPressEnd={onLongPressEnd}
onClick={onCellClick}
tabIndex={getCellTabIndex(kaCellId)}
/>
</div>
{/* 每个过程组的单元格 */}
{sortedPGs.map((pg) => {
const processes = getProcessesByKaAndPg(ka.id, pg.id)
{/* 过程格子5个过程组列 */}
{sortedPGs.map((pg) => {
const processes = getProcessesByKaAndPg(ka.id, pg.id)
return (
<td
key={pg.id}
className="p-2 border border-gray-200 dark:border-gray-700 align-top bg-white dark:bg-gray-800"
>
<div className="space-y-1">
{processes.map((p) => {
const processCellId = `process-${p.id}`
if (processes.length === 0) {
// 空单元格:置灰,不可聚焦
return (
<div
key={pg.id}
className="empty-cell bg-gray-100 dark:bg-gray-800 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg"
style={{ minHeight: '50px' }}
aria-hidden="true"
/>
)
}
return (
<div key={pg.id} className="process-column flex flex-col gap-1.5">
{processes.map((p) => {
const processCellId = `process-${p.id}`
return (
<ProcessCell
key={p.id}
process={p}
isAnswered={answeredCells.get(processCellId) || false}
isFocused={currentCellId === processCellId}
showAnswer={
showAnswerForCell?.cellId === processCellId
? showAnswerForCell.answer
: null
}
onLongPress={onLongPress}
onLongPressEnd={onLongPressEnd}
onClick={onCellClick}
tabIndex={getCellTabIndex(processCellId)}
/>
)
})}
</div>
)
})}
</div>
)
})}
return (
<ProcessCell
key={p.id}
process={p}
isAnswered={answeredCells.get(processCellId) || false}
isFocused={currentCellId === processCellId}
showAnswer={
showAnswerForCell?.cellId === processCellId
? showAnswerForCell.answer
: null
}
onLongPress={onLongPress}
onLongPressEnd={onLongPressEnd}
onClick={onCellClick}
tabIndex={getCellTabIndex(processCellId)}
/>
)
})}
</div>
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -302,7 +302,6 @@ export default function ProcessPracticePage() {
{/* 矩阵区域 */}
<div className="max-w-7xl mx-auto py-4">
<PracticeMatrix
cellSequence={cellSequence}
answeredCells={answeredCells}
currentCellId={currentCellId}
showAnswerForCell={showAnswerForCell}
@@ -315,10 +314,10 @@ export default function ProcessPracticePage() {
</div>
{/* 底部固定区域 */}
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 z-10">
<div className="fixed bottom-0 left-0 right-0 bg-white/80 dark:bg-gray-800/80 backdrop-blur-md border-t border-gray-200 dark:border-gray-700 z-10">
<div className="max-w-7xl mx-auto">
{/* 输入区域 */}
<div className="py-4 border-b border-gray-200 dark:border-gray-700">
<div className="py-4 border-b border-gray-200/50 dark:border-gray-700/50">
<InputArea
userInput={userInput}
charStatuses={charStatuses}
@@ -333,7 +332,7 @@ export default function ProcessPracticePage() {
</div>
{/* 辅助信息区域 */}
<div className="py-3 px-4 max-h-32 overflow-y-auto">
<div className="py-3 px-4 max-h-40 overflow-y-auto">
<HintInfo currentCell={currentCell} />
</div>
</div>

View File

@@ -56,8 +56,8 @@ export function generateCellSequence(): CellInfo[] {
id: `ka-${ka.id}`,
type: 'knowledge-area',
knowledgeAreaId: ka.id,
answer: ka.name.replace('项目', ''), // "项目整合管理" -> "整合管理"
normalizedAnswer: normalizeAnswer(ka.name, true), // 知识领域去除"项目"
answer: ka.name, // 保留完整名称 "项目整合管理"
normalizedAnswer: normalizeAnswer(ka.name, true), // 标准化时去除"项目"
order: order++,
})