fix(整合): 修复十二项原则布局、格子配色及菜单位置
This commit is contained in:
@@ -18,10 +18,10 @@ const navItems = [
|
||||
{ path: '/', label: '首页', icon: Home },
|
||||
{ path: '/process-matrix', label: '49过程矩阵', icon: LayoutGrid },
|
||||
{ path: '/process-practice', label: '过程背诵练习', icon: GraduationCap },
|
||||
{ path: '/principles', label: '十二项原则', icon: BookMarked },
|
||||
{ path: '/knowledge-areas', label: '知识领域', icon: BookOpen },
|
||||
{ path: '/process-groups', label: '过程组', icon: Layers },
|
||||
{ path: '/process-graph', label: '过程关系图', icon: Share2 },
|
||||
{ path: '/principles', label: '十二项原则', icon: BookMarked },
|
||||
{ path: '/settings', label: '设置', icon: Settings },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"changelogEntries": [
|
||||
{
|
||||
"id": "2026-03-18-principles-layout-fix",
|
||||
"date": "2026-03-18",
|
||||
"type": "fix",
|
||||
"title": "修复十二项原则布局(sticky结构)、格子配色及菜单位置",
|
||||
"scope": "整合"
|
||||
},
|
||||
{
|
||||
"id": "2026-03-18-principles-page",
|
||||
"date": "2026-03-18",
|
||||
|
||||
@@ -413,208 +413,213 @@ export default function PrinciplesPage() {
|
||||
|
||||
// ─── 渲染 ─────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 px-4 py-8 dark:bg-slate-900">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
// 与 ProcessPracticePage 保持相同的 flex-col 布局结构
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex flex-col">
|
||||
|
||||
{/* 标题栏 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-blue-600 dark:text-blue-400">
|
||||
PMBOK 第七版
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{/* 顶部粘性区:标题 + 进度条 */}
|
||||
<div className="sticky top-0 z-20 bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
十二项原则
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isPracticeMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetPractice}
|
||||
className="rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition-colors hover:border-red-300 hover:text-red-600 dark:border-slate-600 dark:text-slate-300 dark:hover:text-red-400"
|
||||
>
|
||||
重置进度
|
||||
</button>
|
||||
)}
|
||||
<div className="flex rounded-xl bg-slate-100 p-1 dark:bg-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPracticeMode(false)}
|
||||
className={clsx(
|
||||
'rounded-lg px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
!isPracticeMode
|
||||
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
||||
)}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPracticeMode(true)}
|
||||
className={clsx(
|
||||
'rounded-lg px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
isPracticeMode
|
||||
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
||||
)}
|
||||
>
|
||||
练习
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{isPracticeMode && (
|
||||
<>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
进度:{answeredCount} / {principles.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetPractice}
|
||||
className="text-xs px-2 py-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
清除进度
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPracticeMode(false)}
|
||||
className={clsx(
|
||||
'rounded-md px-3 py-1 text-sm font-medium transition-colors',
|
||||
!isPracticeMode
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
)}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPracticeMode(true)}
|
||||
className={clsx(
|
||||
'rounded-md px-3 py-1 text-sm font-medium transition-colors',
|
||||
isPracticeMode
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
)}
|
||||
>
|
||||
练习
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{isPracticeMode && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-2 flex-1 rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
{isPracticeMode && (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-blue-500 transition-all duration-300"
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(answeredCount / principles.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-sm text-slate-500 dark:text-slate-400">
|
||||
{answeredCount} / {principles.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间可滚动区:原则表格 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto py-4 px-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-[720px] w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{principleGroups.map((group) => (
|
||||
<th
|
||||
key={group.id}
|
||||
className="bg-blue-700 px-4 py-3 text-center text-base font-bold text-white dark:bg-blue-800"
|
||||
>
|
||||
{group.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[0, 1, 2, 3].map((rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
{principleGroups.map((group) => {
|
||||
const principle = group.items[rowIdx]
|
||||
if (!principle) {
|
||||
return (
|
||||
<td
|
||||
key={`${group.id}-empty`}
|
||||
className="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isAnswered = !!answeredCells.get(principle.id)
|
||||
const isCurrent =
|
||||
isPracticeMode && principle.id === currentPrincipleId
|
||||
const isShowingAnswer =
|
||||
showAnswerForCell?.principleId === principle.id
|
||||
|
||||
{/* 原则表格 */}
|
||||
<div className="overflow-x-auto rounded-2xl shadow-sm ring-1 ring-slate-200 dark:ring-slate-700">
|
||||
<table className="min-w-[840px] w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{principleGroups.map((group) => (
|
||||
<th
|
||||
key={group.id}
|
||||
className="bg-blue-700 px-4 py-3 text-center text-base font-bold text-white dark:bg-blue-800"
|
||||
>
|
||||
{group.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[0, 1, 2, 3].map((rowIdx) => (
|
||||
<tr key={rowIdx} className="divide-x divide-slate-100 dark:divide-slate-700">
|
||||
{principleGroups.map((group) => {
|
||||
const principle = group.items[rowIdx]
|
||||
if (!principle) {
|
||||
return (
|
||||
<td
|
||||
key={`${group.id}-empty`}
|
||||
className="bg-white dark:bg-slate-800"
|
||||
/>
|
||||
)
|
||||
}
|
||||
key={principle.id}
|
||||
className="border border-gray-200 dark:border-gray-700 p-0 align-top"
|
||||
>
|
||||
<div className="flex min-h-[72px]">
|
||||
{/* 原则名称列 */}
|
||||
{isPracticeMode ? (
|
||||
<button
|
||||
type="button"
|
||||
data-principle-id={principle.id}
|
||||
onClick={() => switchToPrinciple(principle)}
|
||||
onPointerDown={() => handlePointerDown(principle.id)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
aria-label={`原则:${principle.name}`}
|
||||
className={clsx(
|
||||
'relative flex w-32 shrink-0 items-center justify-center',
|
||||
'border-r border-2 cursor-pointer transition-all duration-200 focus:outline-none',
|
||||
'border-r-gray-200 dark:border-r-gray-700',
|
||||
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',
|
||||
isCurrent && 'ring-2 ring-blue-500 border-blue-500',
|
||||
!isAnswered && 'min-h-[40px]'
|
||||
)}
|
||||
>
|
||||
{isAnswered && (
|
||||
<span className="px-2 text-xs font-semibold text-gray-900 dark:text-gray-100 text-center leading-snug">
|
||||
{principle.name}
|
||||
</span>
|
||||
)}
|
||||
{isShowingAnswer && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 rounded z-10">
|
||||
<span className="text-white text-sm font-medium px-2 text-center">
|
||||
{showAnswerForCell.answer}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex w-32 shrink-0 items-center justify-center bg-blue-700 px-2 py-3 text-center text-sm font-bold leading-snug text-white dark:bg-blue-800">
|
||||
{principle.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
const isAnswered = !!answeredCells.get(principle.id)
|
||||
const isCurrent =
|
||||
isPracticeMode && principle.id === currentPrincipleId
|
||||
const isShowingAnswer =
|
||||
showAnswerForCell?.principleId === principle.id
|
||||
|
||||
return (
|
||||
<td key={principle.id} className="p-0 align-top">
|
||||
<div className="flex min-h-[80px]">
|
||||
{/* 原则名称列 */}
|
||||
{isPracticeMode ? (
|
||||
<button
|
||||
type="button"
|
||||
data-principle-id={principle.id}
|
||||
onClick={() => switchToPrinciple(principle)}
|
||||
onPointerDown={() => handlePointerDown(principle.id)}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
aria-current={isCurrent ? 'step' : undefined}
|
||||
className={clsx(
|
||||
'relative flex w-36 shrink-0 items-center justify-center border-r px-2 py-3 text-center text-sm font-semibold transition-all focus:outline-none',
|
||||
'border-slate-100 dark:border-slate-700',
|
||||
isAnswered &&
|
||||
'bg-green-600 text-white',
|
||||
!isAnswered &&
|
||||
!isCurrent &&
|
||||
'bg-blue-700 text-blue-300 opacity-40 dark:bg-blue-800',
|
||||
isCurrent &&
|
||||
!isAnswered &&
|
||||
'bg-amber-400 text-amber-900 ring-2 ring-inset ring-amber-500',
|
||||
isCurrent &&
|
||||
isAnswered &&
|
||||
'bg-green-600 text-white ring-2 ring-inset ring-amber-500'
|
||||
)}
|
||||
>
|
||||
<span className="leading-snug">
|
||||
{isAnswered
|
||||
? principle.name
|
||||
: isCurrent
|
||||
? '作答中…'
|
||||
: ' '}
|
||||
</span>
|
||||
{isShowingAnswer && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/80 px-2 text-sm font-bold text-white">
|
||||
{showAnswerForCell.answer}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex w-36 shrink-0 items-center justify-center bg-blue-700 px-2 py-3 text-center text-sm font-bold leading-snug text-white dark:bg-blue-800">
|
||||
{principle.name}
|
||||
{/* 描述列 */}
|
||||
<div className="flex flex-1 items-center bg-white px-4 py-3 dark:bg-gray-800">
|
||||
<p className="text-sm leading-relaxed text-gray-700 dark:text-gray-200">
|
||||
{principle.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 描述列 */}
|
||||
<div className="flex flex-1 items-center bg-white px-4 py-3 dark:bg-slate-800">
|
||||
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-200">
|
||||
{principle.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 练习输入区 */}
|
||||
{isPracticeMode && currentPrinciple && (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="mb-5 border-b border-slate-100 pb-5 dark:border-slate-700">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-blue-600 dark:text-blue-400">
|
||||
当前提示 · {currentPrinciple.categoryId === 'people' ? '人' : currentPrinciple.categoryId === 'environment' ? '环境' : '事'}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{currentPrinciple.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<InputArea
|
||||
userInput={userInput}
|
||||
charStatuses={charStatuses}
|
||||
isComposing={isComposing}
|
||||
inputLocked={inputLocked}
|
||||
lastErrorTimestamp={lastErrorTimestamp}
|
||||
onInputChange={handleInputChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-center text-xs text-slate-400 dark:text-slate-500">
|
||||
长按单元格 / Ctrl+H 查看答案 · Tab / Shift+Tab 切换题目 · Esc 清空输入
|
||||
</p>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部粘性区:练习输入(与 ProcessPracticePage 结构一致) */}
|
||||
{isPracticeMode && currentPrinciple && (
|
||||
<div className="sticky bottom-0 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md border-t border-gray-200 dark:border-gray-700 z-10 pb-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="py-3 border-b border-gray-200/50 dark:border-gray-700/50">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<InputArea
|
||||
userInput={userInput}
|
||||
charStatuses={charStatuses}
|
||||
isComposing={isComposing}
|
||||
inputLocked={inputLocked}
|
||||
lastErrorTimestamp={lastErrorTimestamp}
|
||||
onInputChange={handleInputChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => currentPrincipleId && handleLongPress(currentPrincipleId)}
|
||||
className="p-2 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title="查看答案(长按格子也可以)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{currentPrinciple.description}
|
||||
<span className="ml-3 text-gray-400 dark:text-gray-500">
|
||||
· 长按格子 / Ctrl+H 查看答案 · Tab 切换
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无障碍播报区 */}
|
||||
<div
|
||||
id="aria-live-region"
|
||||
|
||||
Reference in New Issue
Block a user