Skip to content

Commit 7dc119e

Browse files
committed
perf(pomodoro): P3-19 memo 化(CycleMarkers + 预设标签栏抽取,避免每秒重渲染)
1 parent 608bfed commit 7dc119e

3 files changed

Lines changed: 116 additions & 60 deletions

File tree

‎client/src/features/pomodoro/components/CycleMarkers.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
* @ai-context: 从 PomodoroPage 内联 JSX 抽取为独立组件,
88
* 供 PresetEditor 预览和 ImmersiveTimer 复用。
99
* @ai-context: Extracted energy-bar markers; count driven by preset.longBreakInterval.
10+
* @ai-context: P3-19 React.memo 包裹——props(total/filled/className)仅在完成数或
11+
* 预设变化时改变,避免番茄钟每秒 tick 引发的父组件重渲染连带本组件空转。
1012
*/
13+
import { memo } from 'react';
1114
import { motion } from 'framer-motion';
1215
import { cn } from '@/lib/utils';
1316
import { SPRING, BEAT } from '@/lib/animation/springConfig';
@@ -20,7 +23,7 @@ interface CycleMarkersProps {
2023
className?: string;
2124
}
2225

23-
export default function CycleMarkers({ total, filled, className }: CycleMarkersProps) {
26+
export default memo(function CycleMarkers({ total, filled, className }: CycleMarkersProps) {
2427
if (total <= 0) return null;
2528

2629
return (
@@ -49,4 +52,4 @@ export default function CycleMarkers({ total, filled, className }: CycleMarkersP
4952
</span>
5053
</div>
5154
);
52-
}
55+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* PresetTabs — 番茄钟预设横向标签栏
3+
*
4+
* 从 PomodoroPage 内联 JSX 抽取并 React.memo 化(P3-19):番茄钟每秒 tick
5+
* 会触发父组件重渲染,但标签栏仅依赖 presets/activePresetId,与倒计时无关,
6+
* memo 后避免每秒重建 motion.button 列表。
7+
*
8+
* @ai-context: 设置页/番茄钟组件:PresetTabs。纯展示 + 回调上抛,无内部状态。
9+
*/
10+
import { memo } from 'react';
11+
import { motion } from 'framer-motion';
12+
import { BookOpen, PenLine, BookMarked, Brain, Timer, Moon, Coffee, Dumbbell, Music, Languages, Calculator, Microscope, GraduationCap, Plus } from 'lucide-react';
13+
import { cn } from '@/lib/utils';
14+
import { SPRING } from '@/lib/animation/springConfig';
15+
import type { PomodoroPreset } from '@/types/models';
16+
17+
/** 预设图标映射(lucide 图标名 → 组件) */
18+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19+
const PRESET_ICONS: Record<string, React.ComponentType<any>> = {
20+
GraduationCap, BookOpen, PenLine, BookMarked, Brain, Timer,
21+
Moon, Coffee, Dumbbell, Music, Languages, Calculator, Microscope,
22+
};
23+
24+
interface PresetTabsProps {
25+
presets: PomodoroPreset[];
26+
activePresetId?: string;
27+
canCreate: boolean;
28+
onSelect: (id: string) => void;
29+
onCreate: () => void;
30+
}
31+
32+
export default memo(function PresetTabs({ presets, activePresetId, canCreate, onSelect, onCreate }: PresetTabsProps) {
33+
return (
34+
<motion.div
35+
className="flex items-center gap-0.5 p-1 bg-bg-secondary/60 backdrop-blur-sm rounded-full border border-border/20 max-w-full overflow-x-auto"
36+
initial={{ opacity: 0, y: -10 }}
37+
animate={{ opacity: 1, y: 0 }}
38+
transition={{ delay: 0.1, ...SPRING.gentle }}
39+
>
40+
{presets.map((preset) => {
41+
const Icon = PRESET_ICONS[preset.icon] ?? BookOpen;
42+
const isActive = activePresetId === preset.id;
43+
return (
44+
<motion.button
45+
key={preset.id}
46+
onClick={() => onSelect(preset.id)}
47+
whileTap={{ scale: 0.97 }}
48+
className={cn(
49+
'relative flex items-center gap-1.5 px-4 py-2 rounded-full text-[13px] font-medium transition-all duration-300 whitespace-nowrap',
50+
isActive
51+
? 'text-white shadow-[0_2px_12px_rgba(91,138,114,0.3)]'
52+
: 'text-text-secondary hover:text-text-primary',
53+
)}
54+
>
55+
{isActive && (
56+
<motion.div
57+
layoutId="pomo-mode-bg"
58+
className="absolute inset-0 rounded-full bg-brand-500"
59+
transition={SPRING.default}
60+
/>
61+
)}
62+
<span className="relative flex items-center gap-1.5">
63+
<Icon className="w-4 h-4" strokeWidth={1.5} />
64+
{preset.name}
65+
<span className={cn('text-[10px] opacity-60', isActive && 'opacity-80')}>· {preset.workDuration}min</span>
66+
</span>
67+
</motion.button>
68+
);
69+
})}
70+
{/* "+" 快捷创建预设入口(达上限时隐藏) */}
71+
{canCreate && (
72+
<motion.button
73+
onClick={onCreate}
74+
whileTap={{ scale: 0.9 }}
75+
className="flex items-center justify-center w-8 h-8 rounded-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary/60 transition-all duration-200 flex-shrink-0"
76+
aria-label="新建预设"
77+
>
78+
<Plus className="w-4 h-4" strokeWidth={1.5} />
79+
</motion.button>
80+
)}
81+
</motion.div>
82+
);
83+
});

‎client/src/features/pomodoro/pages/PomodoroPage.tsx‎

Lines changed: 28 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/**
22
* @ai-context: pomodoro 功能模块页面:PomodoroPage。
33
*/
4-
import { useEffect, useRef, useState, useMemo } from 'react';
4+
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
55
import { createPortal } from 'react-dom';
66
import { AnimatePresence, motion } from 'framer-motion';
7-
import { Play, Pause, RotateCcw, SkipForward, Clock, Volume2, VolumeX, Focus, Plus, GraduationCap, BookOpen, PenLine, BookMarked, Brain, Timer, Moon, Coffee, Dumbbell, Music, Languages, Calculator, Microscope } from 'lucide-react';
7+
import { Play, Pause, RotateCcw, SkipForward, Clock, Volume2, VolumeX, Focus } from 'lucide-react';
88
import { cn } from '@/lib/utils';
99
import { db } from '@/lib/storage';
1010
import TimerRing from '../components/TimerRing';
@@ -13,6 +13,8 @@ import ImmersiveTimer from '../components/ImmersiveTimer';
1313
import SlideToExit from '../components/SlideToExit';
1414
import CycleMarkers from '../components/CycleMarkers';
1515
import PresetEditor from '../components/PresetEditor';
16+
import PresetTabs from '../components/PresetTabs';
17+
import { CompletionCelebration } from '../components/CompletionCelebration';
1618
import { usePomodoroStore } from '../store/usePomodoroStore';
1719
import { useShallow } from 'zustand/react/shallow';
1820
import { useAudioPlayer } from '@/lib/audio/useAudioPlayer';
@@ -27,20 +29,14 @@ const WHITE_NOISE_FADE_MS = 1000;
2729
const WHITE_NOISE_FADE_OUT_MS = 1500;
2830
const TIMER_TICK_INTERVAL_MS = 1000;
2931

30-
/** 预设图标映射(lucide 图标名 → 组件) */
31-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32-
const PRESET_ICONS: Record<string, React.ComponentType<any>> = {
33-
GraduationCap, BookOpen, PenLine, BookMarked, Brain, Timer,
34-
Moon, Coffee, Dumbbell, Music, Languages, Calculator, Microscope,
35-
};
36-
3732
export default function PomodoroPage() {
3833
const {
3934
phase, isRunning, isPaused, remainingSeconds, totalSeconds,
4035
completedCount, settings, currentGoal, isImmersive,
4136
activePreset, presets,
4237
start, pause, resume, reset, skip, setPreset, setCurrentGoal,
4338
enterImmersive, exitImmersive, tick, createPreset,
39+
showCompletionOverlay, dismissCompletionOverlay, lastSessionActualDuration,
4440
} = usePomodoroStore(useShallow(s => s));
4541

4642
usePomodoroEffects();
@@ -106,6 +102,9 @@ export default function PomodoroPage() {
106102
else setGoalModalOpen(true);
107103
};
108104

105+
// P3-19 稳定回调引用,供 memo 化的 PresetTabs 避免每秒重渲染
106+
const openPresetEditor = useCallback(() => setPresetEditorOpen(true), []);
107+
109108
/**
110109
* 提交目标并开始番茄。goal 为空字符串时表示“跳过目标”:
111110
* 仍然启动计时,但不记录目标(currentGoal 置 null,也不写目标记忆库)。
@@ -180,55 +179,14 @@ export default function PomodoroPage() {
180179
>
181180
{/* 背景环境光 — 由3D场景提供,已移除 */}
182181

183-
{/* Preset tabs — 横向滚动预设列表 */}
184-
<motion.div
185-
className="flex items-center gap-0.5 p-1 bg-bg-secondary/60 backdrop-blur-sm rounded-full border border-border/20 max-w-full overflow-x-auto"
186-
initial={{ opacity: 0, y: -10 }}
187-
animate={{ opacity: 1, y: 0 }}
188-
transition={{ delay: 0.1, ...SPRING.gentle }}
189-
>
190-
{presets.map((preset) => {
191-
const Icon = PRESET_ICONS[preset.icon] ?? BookOpen;
192-
const isActive = activePreset?.id === preset.id;
193-
return (
194-
<motion.button
195-
key={preset.id}
196-
onClick={() => setPreset(preset.id)}
197-
whileTap={{ scale: 0.97 }}
198-
className={cn(
199-
'relative flex items-center gap-1.5 px-4 py-2 rounded-full text-[13px] font-medium transition-all duration-300 whitespace-nowrap',
200-
isActive
201-
? 'text-white shadow-[0_2px_12px_rgba(91,138,114,0.3)]'
202-
: 'text-text-secondary hover:text-text-primary',
203-
)}
204-
>
205-
{isActive && (
206-
<motion.div
207-
layoutId="pomo-mode-bg"
208-
className="absolute inset-0 rounded-full bg-brand-500"
209-
transition={SPRING.default}
210-
/>
211-
)}
212-
<span className="relative flex items-center gap-1.5">
213-
<Icon className="w-4 h-4" strokeWidth={1.5} />
214-
{preset.name}
215-
<span className={cn('text-[10px] opacity-60', isActive && 'opacity-80')}>· {preset.workDuration}min</span>
216-
</span>
217-
</motion.button>
218-
);
219-
})}
220-
{/* "+" 快捷创建预设入口(达上限时隐藏) */}
221-
{presets.length < MAX_PRESETS && (
222-
<motion.button
223-
onClick={() => setPresetEditorOpen(true)}
224-
whileTap={{ scale: 0.9 }}
225-
className="flex items-center justify-center w-8 h-8 rounded-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary/60 transition-all duration-200 flex-shrink-0"
226-
aria-label="新建预设"
227-
>
228-
<Plus className="w-4 h-4" strokeWidth={1.5} />
229-
</motion.button>
230-
)}
231-
</motion.div>
182+
{/* Preset tabs — 横向滚动预设列表(P3-19 memo 化) */}
183+
<PresetTabs
184+
presets={presets}
185+
activePresetId={activePreset?.id}
186+
canCreate={presets.length < MAX_PRESETS}
187+
onSelect={setPreset}
188+
onCreate={openPresetEditor}
189+
/>
232190

233191
{/* 预设提示 */}
234192
<motion.div
@@ -383,6 +341,18 @@ export default function PomodoroPage() {
383341
</motion.div>
384342
)}
385343
</AnimatePresence>
344+
345+
{/* v0.29: 深潜完成庆祝覆盖层 */}
346+
<CompletionCelebration
347+
visible={showCompletionOverlay}
348+
durationSeconds={lastSessionActualDuration ?? (activePreset?.workDuration ?? settings.workDuration) * 60}
349+
goal={currentGoal}
350+
presetName={activePreset?.name ?? null}
351+
totalDepth={((completedCount + 1) * (activePreset?.workDuration ?? 25)) * 4}
352+
depthGained={(lastSessionActualDuration ?? (activePreset?.workDuration ?? 25) * 60) / 60 * 4}
353+
onClose={dismissCompletionOverlay}
354+
onContinue={() => { dismissCompletionOverlay(); setGoalModalOpen(true); }}
355+
/>
386356
</>
387357
);
388358
}

0 commit comments

Comments
 (0)