Skip to content

Commit 74f1893

Browse files
committed
feat(pomodoro): 修复计时状态机与数据一致性问题,接线体验增强功能
- 修复墙钟校准分支吞掉休息阶段(remainingSeconds 置 0 的回归) - 修复庆祝层累计深度随周期回绕倒退,统一读取珊瑚生态真实深度 - 新增中断会话记录(abortSession):reset/skip 工作阶段落库 interrupted - skip/reset 语义统一:跳过不计完成数、重置清空周期计数(与统计口径一致) - 静默预设跳过启停音效(BUG-005 语义完整化) - 运行中切换预设立即应用新时长并重设墙钟截止点(落库时长不失真) - updateSettings 钳制 0 分钟输入;GoalInput 防中文输入法选词误提交 - initialize 串行化防并发覆盖;移除 setMode 兼容层(业务已迁移 setPreset) - 移除强制沉浸:目标提交不再自动进入沉浸,resume 不自动重入 - 接线创新功能(深潜设置 → 体验增强,全部缺省关闭):休息记忆重放、 心流音乐、守护灵联动、阶段音轨自动切换 - BGM 播放接线(此前设置页有配置 UI 但无播放逻辑) - 音频播放器全局化(audioPrefsStore + PomodoroAudioLayer):切页后 计时与背景音均不中断;页面层只读写偏好 store - 页面拆分至单文件 ≤300 行规范:设置页 793→286、统计页 ~500→290、 ImmersiveTimer 414→263、主页面 444→333(新增 settings/stats/子组件目录) - 统计页:范围查询替代全表加载、按预设分组筛选、日期补零 - 测试:更新 5 个旧语义用例 + 新增 14 个回归测试(984/984 通过)
1 parent 96ac0f5 commit 74f1893

26 files changed

Lines changed: 2184 additions & 1343 deletions

‎client/src/App.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { EyeCareModeStyle, useEyeCareMode } from '@/components/EyeCareMode';
4242
import '@/stores/useSettingsStore'; // 导入以触发音效设置初始化
4343
import { startPomodoroScheduler } from '@/features/pomodoro/lib/pomodoroScheduler';
4444
import { usePomodoroStore } from '@/features/pomodoro/store/usePomodoroStore';
45+
import { PomodoroAudioLayer } from '@/features/pomodoro/components/PomodoroAudioLayer';
4546

4647
// 启动时预加载所有音效(不阻塞渲染)
4748
soundPlayer.preloadAll();
@@ -198,6 +199,10 @@ function App() {
198199
<WorldRecap />
199200
<WorldSoundscape />
200201

202+
{/* 番茄钟全局音频层:白噪音/BGM 播放器与页面生命周期解耦,
203+
切到其他模块时计时与背景音继续(仅渲染 null) */}
204+
<PomodoroAudioLayer />
205+
201206
{/* 3.9 专注守护灵 — 全屏覆盖层(L3+) */}
202207
{focusGuardianEnabled && (
203208
<FocusGuardianOverlay

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export default function GoalInput({
4545
};
4646

4747
const handleKeyDown = (e: React.KeyboardEvent) => {
48-
if (e.key === 'Enter') {
48+
// 中文输入法选词时的回车(composition)不应提交表单,否则选词会误开始番茄
49+
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
4950
e.preventDefault();
5051
handleSubmit();
5152
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* 沉浸模式 — 底部操作区(背景音 + 暂停/继续 + 停止)
3+
*
4+
* @ai-context: 从 ImmersiveTimer 拆分(单文件 ≤300 行规范),纯展示组件。
5+
*/
6+
import { motion, AnimatePresence } from 'framer-motion';
7+
import { cn } from '@/lib/utils';
8+
import { Tip } from '@/components/ui/Tip';
9+
import { Pause, Play, Square, Volume2, VolumeX } from 'lucide-react';
10+
11+
interface ImmersiveControlsProps {
12+
whiteNoiseEnabled?: boolean;
13+
whiteNoiseVolume?: number;
14+
onToggleWhiteNoise?: () => void;
15+
onWhiteNoiseVolume?: (vol: number) => void;
16+
isRunning: boolean;
17+
onPause: () => void;
18+
onResume: () => void;
19+
onReset: () => void;
20+
}
21+
22+
export function ImmersiveControls({
23+
whiteNoiseEnabled = false,
24+
whiteNoiseVolume = 0.5,
25+
onToggleWhiteNoise,
26+
onWhiteNoiseVolume,
27+
isRunning,
28+
onPause,
29+
onResume,
30+
onReset,
31+
}: ImmersiveControlsProps) {
32+
return (
33+
<motion.div
34+
className="absolute bottom-16 flex items-center gap-6"
35+
initial={{ opacity: 0, y: 12 }}
36+
animate={{ opacity: 1, y: 0 }}
37+
transition={{ ...{ duration: 0.4, ease: 'easeOut' }, delay: 0.2 }}
38+
>
39+
{/* 背景音开关 + 音量调节 */}
40+
{onToggleWhiteNoise && (
41+
<div className="flex items-center gap-2">
42+
<Tip text={whiteNoiseEnabled ? '关闭背景音' : '开启背景音'}>
43+
<motion.button
44+
whileHover={{ scale: 1.1 }}
45+
whileTap={{ scale: 0.9 }}
46+
onClick={onToggleWhiteNoise}
47+
aria-label={whiteNoiseEnabled ? '关闭背景音' : '开启背景音'}
48+
className={cn(
49+
'w-10 h-10 rounded-full flex items-center justify-center',
50+
'bg-white/5 border backdrop-blur-sm',
51+
'transition-colors duration-200',
52+
whiteNoiseEnabled
53+
? 'border-white/15 text-white/80 hover:text-white'
54+
: 'border-white/8 text-white/35 hover:text-white/60 hover:bg-white/8',
55+
)}
56+
>
57+
{whiteNoiseEnabled
58+
? <Volume2 className="w-4 h-4" strokeWidth={1.5} />
59+
: <VolumeX className="w-4 h-4" strokeWidth={1.5} />}
60+
</motion.button>
61+
</Tip>
62+
<AnimatePresence>
63+
{whiteNoiseEnabled && onWhiteNoiseVolume && (
64+
<motion.input
65+
key="vol-slider"
66+
type="range"
67+
min={0}
68+
max={1}
69+
step={0.05}
70+
value={whiteNoiseVolume}
71+
onChange={(e) => onWhiteNoiseVolume(parseFloat(e.target.value))}
72+
aria-label="背景音音量"
73+
className="w-20 h-1 cursor-pointer accent-[#5B8A72]"
74+
initial={{ opacity: 0, width: 0 }}
75+
animate={{ opacity: 1, width: 80 }}
76+
exit={{ opacity: 0, width: 0 }}
77+
transition={{ duration: 0.25, ease: 'easeOut' }}
78+
/>
79+
)}
80+
</AnimatePresence>
81+
</div>
82+
)}
83+
84+
{/* 暂停/继续按钮 */}
85+
<Tip text={isRunning ? '暂停' : '继续'}>
86+
<motion.button
87+
whileHover={{ scale: 1.1 }}
88+
whileTap={{ scale: 0.9 }}
89+
onClick={isRunning ? onPause : onResume}
90+
className={cn(
91+
'w-12 h-12 rounded-full flex items-center justify-center',
92+
'bg-white/8 backdrop-blur-sm border border-white/10',
93+
'text-white/60 hover:text-white/90 hover:bg-white/12',
94+
'transition-colors duration-200',
95+
)}
96+
>
97+
{isRunning
98+
? <Pause className="w-5 h-5" strokeWidth={1.5} />
99+
: <Play className="w-5 h-5 ml-0.5" strokeWidth={1.5} />}
100+
</motion.button>
101+
</Tip>
102+
103+
{/* 停止按钮 */}
104+
<Tip text="停止">
105+
<motion.button
106+
whileHover={{ scale: 1.1 }}
107+
whileTap={{ scale: 0.9 }}
108+
onClick={onReset}
109+
className={cn(
110+
'w-10 h-10 rounded-full flex items-center justify-center',
111+
'bg-white/5 border border-white/8',
112+
'text-white/40 hover:text-white/70 hover:bg-white/8',
113+
'transition-colors duration-200',
114+
)}
115+
>
116+
<Square className="w-4 h-4" strokeWidth={1.5} />
117+
</motion.button>
118+
</Tip>
119+
</motion.div>
120+
);
121+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* 沉浸模式 — 中央计时圆环(SVG 弧形光带 + 呼吸倒计时)
3+
*
4+
* @ai-context: 从 ImmersiveTimer 拆分(单文件 ≤300 行规范),纯展示组件。
5+
*/
6+
import { motion, type TargetAndTransition } from 'framer-motion';
7+
8+
const SIZE = 280;
9+
const STROKE_WIDTH = 8;
10+
const R = (SIZE - STROKE_WIDTH) / 2;
11+
const CIRCUMFERENCE = 2 * Math.PI * R;
12+
13+
interface ImmersiveRingProps {
14+
/** 剩余比例 0-1(remainingSeconds / totalSeconds) */
15+
progress: number;
16+
timeStr: string;
17+
label: string;
18+
/** 呼吸动画参数(prefersReduced 时为空对象) */
19+
breatheAnimation: TargetAndTransition;
20+
}
21+
22+
export function ImmersiveRing({ progress, timeStr, label, breatheAnimation }: ImmersiveRingProps) {
23+
return (
24+
<div className="relative flex items-center justify-center">
25+
{/* SVG 弧形光带 */}
26+
<svg
27+
className="w-[65vw] h-[65vw] max-w-[400px] max-h-[400px]"
28+
viewBox={`0 0 ${SIZE} ${SIZE}`}
29+
overflow="visible"
30+
>
31+
<defs>
32+
{/* 光带渐变:brand-500 → accent-500 */}
33+
<linearGradient id="immersive-arc-gradient" x1="0" y1="0" x2="1" y2="1">
34+
<stop offset="0%" stopColor="var(--kb-brand-500, #5B8A72)" />
35+
<stop offset="100%" stopColor="var(--kb-accent-500, #C4956A)" />
36+
</linearGradient>
37+
{/* 发光滤镜 */}
38+
<filter id="arc-glow" x="-30%" y="-30%" width="160%" height="160%">
39+
<feGaussianBlur stdDeviation="4" result="blur" />
40+
<feMerge>
41+
<feMergeNode in="blur" />
42+
<feMergeNode in="SourceGraphic" />
43+
</feMerge>
44+
</filter>
45+
</defs>
46+
47+
{/* 底圈 — 极淡参考线 */}
48+
<circle
49+
cx={SIZE / 2}
50+
cy={SIZE / 2}
51+
r={R}
52+
fill="none"
53+
stroke="rgba(255,255,255,0.04)"
54+
strokeWidth={STROKE_WIDTH}
55+
/>
56+
57+
{/* 弧形光带进度条,strokeDashoffset 变化由 CSS transition 平滑过渡 */}
58+
<circle
59+
cx={SIZE / 2}
60+
cy={SIZE / 2}
61+
r={R}
62+
fill="none"
63+
stroke="url(#immersive-arc-gradient)"
64+
strokeWidth={STROKE_WIDTH}
65+
strokeLinecap="round"
66+
strokeDasharray={CIRCUMFERENCE}
67+
strokeDashoffset={CIRCUMFERENCE * (1 - progress)}
68+
filter="url(#arc-glow)"
69+
className="transition-[stroke-dashoffset] duration-1000 ease-linear"
70+
style={{
71+
transform: 'rotate(-90deg)',
72+
transformOrigin: '50% 50%',
73+
}}
74+
/>
75+
</svg>
76+
77+
{/* 圆环内 — 倒计时数字 + 呼吸缩放 */}
78+
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
79+
<motion.span
80+
className="font-timer font-light tracking-tight leading-none text-white/90"
81+
style={{
82+
fontSize: 'var(--kb-text-timer, clamp(4rem, 10vw, 7rem))',
83+
textShadow: '0 0 30px rgba(91,138,114,0.3), 0 0 60px rgba(91,138,114,0.1)',
84+
fontVariantNumeric: 'tabular-nums',
85+
}}
86+
animate={breatheAnimation}
87+
>
88+
{timeStr}
89+
</motion.span>
90+
<span
91+
className="text-[11px] mt-3 font-medium tracking-[0.15em] uppercase text-white/40"
92+
>
93+
{label}
94+
</span>
95+
</div>
96+
</div>
97+
);
98+
}

0 commit comments

Comments
 (0)