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:
@@ -23,22 +23,17 @@ export function HintInfo({ currentCell }: HintInfoProps) {
|
|||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
敏捷裁剪因素
|
敏捷裁剪因素
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1.5 text-xs">
|
<div className="space-y-2 text-xs">
|
||||||
{ka.tailoringFactors.slice(0, 2).map((factor, index) => (
|
{ka.tailoringFactors.map((factor, index) => (
|
||||||
<div key={index} className="flex gap-2">
|
<div key={index} className="flex gap-2">
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0">
|
<span className="font-medium text-gray-700 dark:text-gray-300 shrink-0">
|
||||||
{factor.title}:
|
{factor.title}:
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600 dark:text-gray-400 line-clamp-1">
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
{factor.description}
|
{factor.description}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{ka.tailoringFactors.length > 2 && (
|
|
||||||
<div className="text-gray-500 dark:text-gray-500">
|
|
||||||
+{ka.tailoringFactors.length - 2} 个因素...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -52,7 +47,7 @@ export function HintInfo({ currentCell }: HintInfoProps) {
|
|||||||
主要作用
|
主要作用
|
||||||
</h3>
|
</h3>
|
||||||
{purpose ? (
|
{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}
|
{purpose}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { knowledgeAreas, processGroups } from '@/data'
|
import { knowledgeAreas, processGroups } from '@/data'
|
||||||
import { getProcessesByKaAndPg, type CellInfo } from '@/utils/practice'
|
import { getProcessesByKaAndPg } from '@/utils/practice'
|
||||||
import { KnowledgeAreaCell } from './KnowledgeAreaCell'
|
|
||||||
import { ProcessCell } from './ProcessCell'
|
import { ProcessCell } from './ProcessCell'
|
||||||
|
|
||||||
interface PracticeMatrixProps {
|
interface PracticeMatrixProps {
|
||||||
cellSequence: CellInfo[]
|
|
||||||
answeredCells: Map<string, boolean>
|
answeredCells: Map<string, boolean>
|
||||||
currentCellId: string | null
|
currentCellId: string | null
|
||||||
showAnswerForCell: { cellId: string; answer: string; expiresAt: number } | null
|
showAnswerForCell: { cellId: string; answer: string; expiresAt: number } | null
|
||||||
@@ -15,7 +13,6 @@ interface PracticeMatrixProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PracticeMatrix({
|
export function PracticeMatrix({
|
||||||
cellSequence,
|
|
||||||
answeredCells,
|
answeredCells,
|
||||||
currentCellId,
|
currentCellId,
|
||||||
showAnswerForCell,
|
showAnswerForCell,
|
||||||
@@ -28,104 +25,90 @@ export function PracticeMatrix({
|
|||||||
const sortedPGs = [...processGroups].sort((a, b) => a.order - b.order)
|
const sortedPGs = [...processGroups].sort((a, b) => a.order - b.order)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="practice-matrix px-2">
|
<div className="overflow-x-auto">
|
||||||
{/* 表头 */}
|
<table className="w-full border-collapse min-w-[900px]">
|
||||||
<div
|
{/* 表头:过程组 */}
|
||||||
className="grid gap-2 mb-3 sticky bg-gray-50 dark:bg-gray-900 py-2 z-10"
|
<thead>
|
||||||
style={{
|
<tr>
|
||||||
gridTemplateColumns: 'repeat(5, 1fr)',
|
<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]">
|
||||||
top: '56px', // 避开顶部进度条
|
知识领域 / 过程组
|
||||||
}}
|
</th>
|
||||||
>
|
{sortedPGs.map((pg) => (
|
||||||
{sortedPGs.map((pg) => (
|
<th
|
||||||
<div
|
key={pg.id}
|
||||||
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]"
|
||||||
className="text-center font-semibold text-xs text-gray-700 dark:text-gray-300"
|
style={{ backgroundColor: pg.color }}
|
||||||
>
|
>
|
||||||
{pg.name}
|
{pg.name}
|
||||||
</div>
|
</th>
|
||||||
))}
|
))}
|
||||||
</div>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
{/* 矩阵内容 */}
|
{/* 表体:知识领域 × 过程 */}
|
||||||
{sortedKAs.map((ka) => {
|
<tbody>
|
||||||
const kaCellId = `ka-${ka.id}`
|
{sortedKAs.map((ka) => {
|
||||||
const kaCellInfo = cellSequence.find((c) => c.id === kaCellId)
|
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
|
{sortedPGs.map((pg) => {
|
||||||
key={ka.id}
|
const processes = getProcessesByKaAndPg(ka.id, pg.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>
|
|
||||||
|
|
||||||
{/* 过程格子(5个过程组列) */}
|
return (
|
||||||
{sortedPGs.map((pg) => {
|
<td
|
||||||
const processes = getProcessesByKaAndPg(ka.id, pg.id)
|
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 (
|
||||||
// 空单元格:置灰,不可聚焦
|
<ProcessCell
|
||||||
return (
|
key={p.id}
|
||||||
<div
|
process={p}
|
||||||
key={pg.id}
|
isAnswered={answeredCells.get(processCellId) || false}
|
||||||
className="empty-cell bg-gray-100 dark:bg-gray-800 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg"
|
isFocused={currentCellId === processCellId}
|
||||||
style={{ minHeight: '50px' }}
|
showAnswer={
|
||||||
aria-hidden="true"
|
showAnswerForCell?.cellId === processCellId
|
||||||
/>
|
? showAnswerForCell.answer
|
||||||
)
|
: null
|
||||||
}
|
}
|
||||||
|
onLongPress={onLongPress}
|
||||||
return (
|
onLongPressEnd={onLongPressEnd}
|
||||||
<div key={pg.id} className="process-column flex flex-col gap-1.5">
|
onClick={onCellClick}
|
||||||
{processes.map((p) => {
|
tabIndex={getCellTabIndex(processCellId)}
|
||||||
const processCellId = `process-${p.id}`
|
/>
|
||||||
|
)
|
||||||
return (
|
})}
|
||||||
<ProcessCell
|
</div>
|
||||||
key={p.id}
|
</td>
|
||||||
process={p}
|
)
|
||||||
isAnswered={answeredCells.get(processCellId) || false}
|
})}
|
||||||
isFocused={currentCellId === processCellId}
|
</tr>
|
||||||
showAnswer={
|
)
|
||||||
showAnswerForCell?.cellId === processCellId
|
})}
|
||||||
? showAnswerForCell.answer
|
</tbody>
|
||||||
: null
|
</table>
|
||||||
}
|
|
||||||
onLongPress={onLongPress}
|
|
||||||
onLongPressEnd={onLongPressEnd}
|
|
||||||
onClick={onCellClick}
|
|
||||||
tabIndex={getCellTabIndex(processCellId)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,7 +302,6 @@ export default function ProcessPracticePage() {
|
|||||||
{/* 矩阵区域 */}
|
{/* 矩阵区域 */}
|
||||||
<div className="max-w-7xl mx-auto py-4">
|
<div className="max-w-7xl mx-auto py-4">
|
||||||
<PracticeMatrix
|
<PracticeMatrix
|
||||||
cellSequence={cellSequence}
|
|
||||||
answeredCells={answeredCells}
|
answeredCells={answeredCells}
|
||||||
currentCellId={currentCellId}
|
currentCellId={currentCellId}
|
||||||
showAnswerForCell={showAnswerForCell}
|
showAnswerForCell={showAnswerForCell}
|
||||||
@@ -315,10 +314,10 @@ export default function ProcessPracticePage() {
|
|||||||
</div>
|
</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="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
|
<InputArea
|
||||||
userInput={userInput}
|
userInput={userInput}
|
||||||
charStatuses={charStatuses}
|
charStatuses={charStatuses}
|
||||||
@@ -333,7 +332,7 @@ export default function ProcessPracticePage() {
|
|||||||
</div>
|
</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} />
|
<HintInfo currentCell={currentCell} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,8 +56,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.replace('项目', ''), // "项目整合管理" -> "整合管理"
|
answer: ka.name, // 保留完整名称 "项目整合管理"
|
||||||
normalizedAnswer: normalizeAnswer(ka.name, true), // 知识领域去除"项目"
|
normalizedAnswer: normalizeAnswer(ka.name, true), // 标准化时去除"项目"
|
||||||
order: order++,
|
order: order++,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user