Skip to content

Commit 1f2308b

Browse files
committed
perf(ui): split whole-store subscriptions and fix effect re-registration
P0-1: Split useEcosystemStore() whole-store subscription in useDashboardData into corals + totalDepth fine-grained selectors. Removes unused coralData from return value. Eliminates cascade re-renders of DashboardPage when any ecosystem field changes. P0-2: Split useAudioPrefsStore() whole-store subscription in 4 components (PomodoroControls, ImmersiveTimer, SoundscapeCard, PomodoroAudioLayer) into individual field selectors. Volume slider drag no longer triggers full re-render of unrelated components. P1-1: Fix ImmersiveTimer space key effect re-registering every second due to tick-dependent dependencies. Use useRef to hold latest phase/isRunning/isPaused values, effect now only depends on stable function references (pause, resume).
1 parent 0832b21 commit 1f2308b

5 files changed

Lines changed: 109 additions & 49 deletions

File tree

‎client/src/features/dashboard/hooks/useDashboardData.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,14 @@ export function useDashboardData() {
6969

7070
// ── 留存机制数据准备 ──
7171
const retentionEnabled = useRetentionSettings((s) => s.enabled);
72-
const coralData = useEcosystemStore();
72+
const corals = useEcosystemStore((s) => s.corals);
73+
const totalDepth = useEcosystemStore((s) => s.totalDepth);
7374

7475
// 基于珊瑚种植日期构建 StreakState(StreakBubble 组件所需)
7576
const streakState: StreakState | null = useMemo(() => {
76-
if (!retentionEnabled || coralData.corals.length === 0) return null;
77+
if (!retentionEnabled || corals.length === 0) return null;
7778
const uniqueDays = new Set(
78-
coralData.corals.map((c) => new Date(c.plantedAt).toISOString().split('T')[0]),
79+
corals.map((c) => new Date(c.plantedAt).toISOString().split('T')[0]),
7980
);
8081
const sorted = [...uniqueDays].sort().reverse();
8182
// 连续天数 = 从最近日期向前逐日检查,遇到间隔即停止
@@ -98,7 +99,7 @@ export function useDashboardData() {
9899
restDayPreference: 0,
99100
retainedPercent: 50,
100101
};
101-
}, [retentionEnabled, coralData.corals]);
102+
}, [retentionEnabled, corals]);
102103

103104
const notes = useNoteStore((s) => s.notes);
104105
const loadNotes = useNoteStore((s) => s.loadNotes);
@@ -147,11 +148,11 @@ export function useDashboardData() {
147148
feynmanCompleted: feynmanNotes.filter((n) => n.status === 'completed').length,
148149
totalReviews: flashcardReviews.length,
149150
longestStreak: streakDays,
150-
coralCount: coralData.corals.length,
151-
totalDepth: coralData.totalDepth,
151+
coralCount: corals.length,
152+
totalDepth: totalDepth,
152153
});
153154
return { insights, identityTags };
154-
}, [analytics, feynmanNotes, flashcardReviews, streakDays, coralData]);
155+
}, [analytics, feynmanNotes, flashcardReviews, streakDays, corals, totalDepth]);
155156

156157
// 🧩 五维能力雷达图
157158
const radarData = analytics?.radar ?? [];
@@ -295,7 +296,7 @@ export function useDashboardData() {
295296
// 用户与问候
296297
userName, greetingText,
297298
// 打卡与留存
298-
streakDays, todayCheckIn, checkInLoading, retentionEnabled, coralData, streakState,
299+
streakDays, todayCheckIn, checkInLoading, retentionEnabled, streakState,
299300
// 学习数据与统计
300301
notes, feynmanNotes, isLoading, pomodoroSessions, allCards, flashcardReviews,
301302
todayPomodoroCount, noteTotal, dueFlashcardCount, feynmanInProgressCount,

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

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @ai-context: 3.8 心流音乐引擎 + 3.13 具身学习休息引导集成于此;
88
* 底部操作区已拆至 ImmersiveControls(单文件 ≤300 行规范)。
99
*/
10-
import { useMemo, useState, useEffect } from 'react';
10+
import { useMemo, useState, useEffect, useRef } from 'react';
1111
import { motion } from 'framer-motion';
1212
import { usePomodoroStore } from '../store/usePomodoroStore';
1313
import { BEAT } from '@/lib/animation/springConfig';
@@ -127,12 +127,51 @@ export default function ImmersiveTimer({
127127
reset();
128128
};
129129

130+
// 空格键暂停/继续(排除输入框聚焦场景)
131+
// 使用 ref 持有最新值,避免 effect 每秒因 tick 变化而重建
132+
const phaseRef = useRef(phase);
133+
phaseRef.current = phase;
134+
const isRunningRef = useRef(isRunning);
135+
isRunningRef.current = isRunning;
136+
const isPausedRef = useRef(isPaused);
137+
isPausedRef.current = isPaused;
138+
useEffect(() => {
139+
const onKey = (e: KeyboardEvent) => {
140+
if (e.code !== 'Space') return;
141+
const target = e.target as HTMLElement | null;
142+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
143+
if (phaseRef.current !== 'work') return;
144+
e.preventDefault();
145+
if (isRunningRef.current) pause();
146+
else if (isPausedRef.current) resume();
147+
};
148+
window.addEventListener('keydown', onKey);
149+
return () => window.removeEventListener('keydown', onKey);
150+
}, [pause, resume]);
151+
130152
// 背景音偏好:外部传入优先(向后兼容),缺省读全局音频偏好 store
131-
const storePrefs = useAudioPrefsStore();
132-
const effectiveWhiteNoiseEnabled = whiteNoiseEnabled ?? storePrefs.whiteNoiseEnabled;
133-
const effectiveWhiteNoiseVolume = whiteNoiseVolume ?? storePrefs.whiteNoiseVolume;
134-
const effectiveToggleWhiteNoise = onToggleWhiteNoise ?? storePrefs.toggleWhiteNoise;
135-
const effectiveWhiteNoiseVolumeChange = onWhiteNoiseVolume ?? storePrefs.setWhiteNoiseVolume;
153+
const storeWhiteNoiseEnabled = useAudioPrefsStore((s) => s.whiteNoiseEnabled);
154+
const storeWhiteNoiseVolume = useAudioPrefsStore((s) => s.whiteNoiseVolume);
155+
const storeToggleWhiteNoise = useAudioPrefsStore((s) => s.toggleWhiteNoise);
156+
const storeSetWhiteNoiseVolume = useAudioPrefsStore((s) => s.setWhiteNoiseVolume);
157+
const effectiveWhiteNoiseEnabled = whiteNoiseEnabled ?? storeWhiteNoiseEnabled;
158+
const effectiveWhiteNoiseVolume = whiteNoiseVolume ?? storeWhiteNoiseVolume;
159+
const effectiveToggleWhiteNoise = onToggleWhiteNoise ?? storeToggleWhiteNoise;
160+
const effectiveWhiteNoiseVolumeChange = onWhiteNoiseVolume ?? storeSetWhiteNoiseVolume;
161+
162+
// 时间显示 5s:阶段开头(remaining === total)触发(覆盖运行起始沿与 store 恢复运行态)。
163+
// timer 存 ref:effect 依赖每秒变化的 remainingSeconds,局部变量 timer 会被每秒
164+
// 重跑的 effect cleanup 清除(时间永不隐藏)
165+
const [showTime, setShowTime] = useState(false);
166+
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
167+
useEffect(() => {
168+
if (totalSeconds > 0 && remainingSeconds === totalSeconds) {
169+
setShowTime(true);
170+
if (showTimerRef.current) clearTimeout(showTimerRef.current);
171+
showTimerRef.current = setTimeout(() => setShowTime(false), 5000);
172+
}
173+
}, [remainingSeconds, totalSeconds]);
174+
useEffect(() => () => { if (showTimerRef.current) clearTimeout(showTimerRef.current); }, []);
136175

137176
// 3.8 心流音乐引擎(工作阶段激活)
138177
const flowMusic = useFlowMusic(focusScore);
@@ -239,14 +278,15 @@ export default function ImmersiveTimer({
239278
mood={activePreset?.mood}
240279
onTap={handleChronosTap}
241280
onLongPress={handleChronosLongPress}
281+
showTime={showTime}
242282
timeStr={timeStr}
243283
/>
244284
{/* 状态行(沉浸深色背景专用变体):仅 work 阶段显示(休息时由活动建议卡占据该区域) */}
245285
{phase === 'work' && (
246286
<ChronosStateRow
247287
input={{ isArmed, isRunning, isPaused, phase }}
248288
variant="immersive"
249-
hint={isRunning ? '点击暂停 · 长按放弃' : '点击继续'}
289+
hint={isRunning ? '点击调整一下 · 空格暂停 · 长按放弃' : '点击继续'}
250290
className="absolute bottom-32 left-0 right-0"
251291
/>
252292
)}

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

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,57 +19,62 @@ const FADE_IN_MS = 1000;
1919
const FADE_OUT_MS = 1500;
2020

2121
export function PomodoroAudioLayer() {
22-
const audioPrefs = useAudioPrefsStore();
22+
const whiteNoiseTrackId = useAudioPrefsStore((s) => s.whiteNoiseTrackId);
23+
const whiteNoiseVolume = useAudioPrefsStore((s) => s.whiteNoiseVolume);
24+
const whiteNoiseEnabled = useAudioPrefsStore((s) => s.whiteNoiseEnabled);
25+
const bgmTrackId = useAudioPrefsStore((s) => s.bgmTrackId);
26+
const bgmVolume = useAudioPrefsStore((s) => s.bgmVolume);
27+
const bgmEnabled = useAudioPrefsStore((s) => s.bgmEnabled);
2328
// 独立 selector:内联对象 selector 返回不稳定 snapshot,
2429
// 触发 useSyncExternalStore "getSnapshot should be cached" 无限循环(App 整树崩溃)
2530
const isRunning = usePomodoroStore((s) => s.isRunning);
2631
const phase = usePomodoroStore((s) => s.phase);
2732
const autoSwitchAudioPhase = usePomodoroStore((s) => s.settings.autoSwitchAudioPhase ?? false);
2833

2934
const whiteNoiseTrack = useMemo(
30-
() => audioTracks.find((t) => t.id === audioPrefs.whiteNoiseTrackId) ?? audioTracks[0],
31-
[audioPrefs.whiteNoiseTrackId],
35+
() => audioTracks.find((t) => t.id === whiteNoiseTrackId) ?? audioTracks[0],
36+
[whiteNoiseTrackId],
3237
);
3338
const whiteNoisePlayer = useAudioPlayer({
34-
src: whiteNoiseTrack.src, volume: audioPrefs.whiteNoiseVolume,
39+
src: whiteNoiseTrack.src, volume: whiteNoiseVolume,
3540
loop: true, fadeInMs: FADE_IN_MS, fadeOutMs: FADE_OUT_MS,
3641
});
3742

3843
const bgmTrack = useMemo(
39-
() => audioTracks.find((t) => t.id === audioPrefs.bgmTrackId) ?? audioTracks[audioTracks.length - 1],
40-
[audioPrefs.bgmTrackId],
44+
() => audioTracks.find((t) => t.id === bgmTrackId) ?? audioTracks[audioTracks.length - 1],
45+
[bgmTrackId],
4146
);
4247
const bgmPlayer = useAudioPlayer({
43-
src: bgmTrack.src, volume: audioPrefs.bgmVolume,
48+
src: bgmTrack.src, volume: bgmVolume,
4449
loop: true, fadeInMs: FADE_IN_MS, fadeOutMs: FADE_OUT_MS,
4550
});
4651

4752
// 白噪音:仅工作阶段播放(与计时状态解耦于页面生命周期)
4853
useEffect(() => {
49-
if (isRunning && phase === 'work' && audioPrefs.whiteNoiseEnabled) {
54+
if (isRunning && phase === 'work' && whiteNoiseEnabled) {
5055
whiteNoisePlayer.play();
5156
} else {
5257
whiteNoisePlayer.pause();
5358
}
54-
}, [isRunning, phase, audioPrefs.whiteNoiseEnabled]); // eslint-disable-line
59+
}, [isRunning, phase, whiteNoiseEnabled]); // eslint-disable-line
5560

5661
// BGM:仅工作阶段播放
5762
useEffect(() => {
58-
if (isRunning && phase === 'work' && audioPrefs.bgmEnabled) {
63+
if (isRunning && phase === 'work' && bgmEnabled) {
5964
bgmPlayer.play();
6065
} else {
6166
bgmPlayer.pause();
6267
}
63-
}, [isRunning, phase, audioPrefs.bgmEnabled]); // eslint-disable-line
68+
}, [isRunning, phase, bgmEnabled]); // eslint-disable-line
6469

6570
// 音量变化实时同步(滑杆在页面层,播放器在本层)
6671
useEffect(() => {
67-
whiteNoisePlayer.setVolume(audioPrefs.whiteNoiseVolume);
68-
}, [audioPrefs.whiteNoiseVolume, whiteNoisePlayer]);
72+
whiteNoisePlayer.setVolume(whiteNoiseVolume);
73+
}, [whiteNoiseVolume, whiteNoisePlayer]);
6974

7075
useEffect(() => {
71-
bgmPlayer.setVolume(audioPrefs.bgmVolume);
72-
}, [audioPrefs.bgmVolume, bgmPlayer]);
76+
bgmPlayer.setVolume(bgmVolume);
77+
}, [bgmVolume, bgmPlayer]);
7378

7479
// 阶段音轨自动切换(体验增强开关,默认关闭):休息/专注自动换音轨
7580
useEffect(() => {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ export function PomodoroControls({ orbitRadius }: PomodoroControlsProps) {
5858
const enterImmersive = usePomodoroStore((s) => s.enterImmersive);
5959
// 沉睡态(未激活未运行):沉浸入口常驻但禁用(先开始专注才能进入沉浸)
6060
const isAsleep = !isRunning && !isPaused && !isArmed;
61-
const audioPrefs = useAudioPrefsStore();
62-
const estimated = useEstimatedVolume(audioPrefs.whiteNoiseVolume, audioPrefs.deviceType);
61+
const whiteNoiseVolume = useAudioPrefsStore((s) => s.whiteNoiseVolume);
62+
const deviceType = useAudioPrefsStore((s) => s.deviceType);
63+
const estimated = useEstimatedVolume(whiteNoiseVolume, deviceType);
6364
const [trackPickerOpen, setTrackPickerOpen] = useState(false);
6465
const pickerRef = useRef<HTMLDivElement>(null);
6566

@@ -81,12 +82,12 @@ export function PomodoroControls({ orbitRadius }: PomodoroControlsProps) {
8182
return (
8283
<div className="pointer-events-none">
8384
{/* ── 白噪音卫星(弧右下)── */}
84-
<div ref={pickerRef} className="pointer-events-auto absolute z-20" style={satPos(SAT.noise, orbitRadius)}>
85+
<div ref={pickerRef} className="pointer-events-auto absolute z-10" style={satPos(SAT.noise, orbitRadius)}>
8586
<Tip text={audioPrefs.whiteNoiseEnabled ? '关闭背景音' : '开启背景音'}>
8687
<motion.button
8788
whileTap={{ scale: 0.9 }}
8889
onClick={audioPrefs.toggleWhiteNoise}
89-
className={satClass('w-9 h-9 rounded-full border border-border/30 backdrop-blur-sm bg-bg-elevated/60')}
90+
className={satClass('w-9 h-9 rounded-full border border-border/30 bg-bg-elevated/60 backdrop-blur-sm text-text-tertiary hover:text-text-secondary transition-colors')}
9091
animate={{ opacity: trackPickerOpen ? 1 : SAT_IDLE_OPACITY }}
9192
whileHover={{ opacity: 1 }}
9293
>

‎client/src/features/pomodoro/components/settings/dive/SoundscapeCard.tsx‎

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,20 @@ import { useAudioPrefsStore } from '@/lib/audio/audioPrefsStore';
1212
import { SettingRow, Toggle } from '../shared';
1313

1414
export function SoundscapeCard() {
15-
const audioPrefs = useAudioPrefsStore();
15+
const whiteNoiseEnabled = useAudioPrefsStore((s) => s.whiteNoiseEnabled);
16+
const whiteNoiseTrackId = useAudioPrefsStore((s) => s.whiteNoiseTrackId);
17+
const whiteNoiseVolume = useAudioPrefsStore((s) => s.whiteNoiseVolume);
18+
const bgmEnabled = useAudioPrefsStore((s) => s.bgmEnabled);
19+
const bgmTrackId = useAudioPrefsStore((s) => s.bgmTrackId);
20+
const bgmVolume = useAudioPrefsStore((s) => s.bgmVolume);
21+
const deviceType = useAudioPrefsStore((s) => s.deviceType);
22+
const toggleWhiteNoise = useAudioPrefsStore((s) => s.toggleWhiteNoise);
23+
const setWhiteNoiseTrack = useAudioPrefsStore((s) => s.setWhiteNoiseTrack);
24+
const setWhiteNoiseVolume = useAudioPrefsStore((s) => s.setWhiteNoiseVolume);
25+
const toggleBgm = useAudioPrefsStore((s) => s.toggleBgm);
26+
const setBgmTrack = useAudioPrefsStore((s) => s.setBgmTrack);
27+
const setBgmVolume = useAudioPrefsStore((s) => s.setBgmVolume);
28+
const setDeviceType = useAudioPrefsStore((s) => s.setDeviceType);
1629

1730
return (
1831
<div className="rounded-kb-lg border border-border/40 bg-bg-secondary/60 p-kb-md">
@@ -24,14 +37,14 @@ export function SoundscapeCard() {
2437
<div className="divide-y divide-border/30">
2538
<SettingRow label="白噪音" description="专注阶段自动播放白噪音">
2639
<Toggle
27-
checked={audioPrefs.whiteNoiseEnabled}
28-
onChange={audioPrefs.toggleWhiteNoise}
40+
checked={whiteNoiseEnabled}
41+
onChange={toggleWhiteNoise}
2942
/>
3043
</SettingRow>
3144
<SettingRow label="白噪音轨道" description="选择水下声景">
3245
<select
33-
value={audioPrefs.whiteNoiseTrackId}
34-
onChange={(e) => audioPrefs.setWhiteNoiseTrack(e.target.value)}
46+
value={whiteNoiseTrackId}
47+
onChange={(e) => setWhiteNoiseTrack(e.target.value)}
3548
className="bg-bg-tertiary border border-border/50 rounded-kb-md px-2 py-1 text-b2 text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-500/40"
3649
>
3750
{audioTracks.filter((t) => t.category === 'white_noise').map((t) => (
@@ -47,22 +60,22 @@ export function SoundscapeCard() {
4760
min={0}
4861
max={1}
4962
step={0.05}
50-
value={audioPrefs.whiteNoiseVolume}
51-
onChange={(e) => audioPrefs.setWhiteNoiseVolume(parseFloat(e.target.value))}
63+
value={whiteNoiseVolume}
64+
onChange={(e) => setWhiteNoiseVolume(parseFloat(e.target.value))}
5265
className="w-24 h-1 accent-brand-500 cursor-pointer"
5366
/>
5467
</div>
5568
</SettingRow>
5669
<SettingRow label="背景音乐" description="专注阶段播放轻音乐">
5770
<Toggle
58-
checked={audioPrefs.bgmEnabled}
59-
onChange={audioPrefs.toggleBgm}
71+
checked={bgmEnabled}
72+
onChange={toggleBgm}
6073
/>
6174
</SettingRow>
6275
<SettingRow label="背景音乐轨道" description="选择背景音乐">
6376
<select
64-
value={audioPrefs.bgmTrackId}
65-
onChange={(e) => audioPrefs.setBgmTrack(e.target.value)}
77+
value={bgmTrackId}
78+
onChange={(e) => setBgmTrack(e.target.value)}
6679
className="bg-bg-tertiary border border-border/50 rounded-kb-md px-2 py-1 text-b2 text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-500/40"
6780
>
6881
{audioTracks.filter((t) => t.category === 'bgm').map((t) => (
@@ -78,16 +91,16 @@ export function SoundscapeCard() {
7891
min={0}
7992
max={1}
8093
step={0.05}
81-
value={audioPrefs.bgmVolume}
82-
onChange={(e) => audioPrefs.setBgmVolume(parseFloat(e.target.value))}
94+
value={bgmVolume}
95+
onChange={(e) => setBgmVolume(parseFloat(e.target.value))}
8396
className="w-24 h-1 accent-brand-500 cursor-pointer"
8497
/>
8598
</div>
8699
</SettingRow>
87100
<SettingRow label="音频输出设备" description="选择当前输出设备,音量估算更准确">
88101
<select
89-
value={audioPrefs.deviceType}
90-
onChange={(e) => audioPrefs.setDeviceType(e.target.value as AudioDeviceType)}
102+
value={deviceType}
103+
onChange={(e) => setDeviceType(e.target.value as AudioDeviceType)}
91104
className="bg-bg-tertiary border border-border/50 rounded-kb-md px-2 py-1 text-b2 text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-500/40"
92105
>
93106
{(Object.keys(DEVICE_TYPE_LABELS) as AudioDeviceType[]).map((type) => (

0 commit comments

Comments
 (0)