Skip to content

Commit 945ca57

Browse files
committed
feat(pomodoro): 番茄钟模式预设自定义与周期标记
新增番茄模式预设:内置上课与自习预设,支持自定义预设的增删改与排序;引入周期标记与预警时点、完成音等音效配置;番茄会话关联预设 ID,longBreakInterval 为 0 统一表达无长休。
1 parent c47bdc7 commit 945ca57

11 files changed

Lines changed: 922 additions & 146 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* CycleMarkers — 番茄循环标记(能量条)
3+
*
4+
* 显示当前预设的长休间隔进度:filled 个已完成的番茄 + 剩余空位。
5+
* 数量由预设的 longBreakInterval 驱动,切换预设时自动变化。
6+
*
7+
* @ai-context: 从 PomodoroPage 内联 JSX 抽取为独立组件,
8+
* 供 PresetEditor 预览和 ImmersiveTimer 复用。
9+
* @ai-context: Extracted energy-bar markers; count driven by preset.longBreakInterval.
10+
*/
11+
import { motion } from 'framer-motion';
12+
import { cn } from '@/lib/utils';
13+
import { SPRING, BEAT } from '@/lib/animation/springConfig';
14+
15+
interface CycleMarkersProps {
16+
/** 总标记数(预设的 longBreakInterval) */
17+
total: number;
18+
/** 已填充数(completedCount) */
19+
filled: number;
20+
className?: string;
21+
}
22+
23+
export default function CycleMarkers({ total, filled, className }: CycleMarkersProps) {
24+
if (total <= 0) return null;
25+
26+
return (
27+
<div className={cn('flex items-center gap-1.5', className)}>
28+
{Array.from({ length: total }, (_, i) => {
29+
const isFilled = i < filled;
30+
return (
31+
<motion.div
32+
key={i}
33+
className={cn(
34+
'h-2 rounded-full transition-all',
35+
`duration-[${BEAT.x2}ms]`,
36+
isFilled
37+
? 'bg-brand-500 shadow-[0_0_8px_rgba(91,138,114,0.4)]'
38+
: 'bg-border/30',
39+
)}
40+
style={{ width: isFilled ? '24px' : '16px' }}
41+
initial={{ scaleX: 0 }}
42+
animate={{ scaleX: 1 }}
43+
transition={{ delay: 0.3 + i * 0.05, ...SPRING.bouncy }}
44+
/>
45+
);
46+
})}
47+
<span className="text-[11px] text-text-tertiary/60 ml-2 font-mono tabular-nums">
48+
{filled}/{total}
49+
</span>
50+
</div>
51+
);
52+
}

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
* @ai-context: 通用组件:ImmersiveTimer。
99
*/
1010
import { useRef, useEffect, useMemo } from 'react';
11-
import { motion } from 'framer-motion';
11+
import { motion, AnimatePresence } from 'framer-motion';
1212
import { cn } from '@/lib/utils';
13-
import { Pause, Play, Square } from 'lucide-react';
13+
import { Pause, Play, Square, Volume2, VolumeX } from 'lucide-react';
1414
import { usePomodoroStore } from '../store/usePomodoroStore';
1515
import { useShallow } from 'zustand/react/shallow';
1616
import { useReducedMotion } from '@/hooks/useReducedMotion';
@@ -27,6 +27,17 @@ const PHASE_LABELS: Record<string, string> = {
2727
long_break: '休息中',
2828
};
2929

30+
interface ImmersiveTimerProps {
31+
/** 背景音(白噪音)开关状态 */
32+
whiteNoiseEnabled?: boolean;
33+
/** 背景音音量 0-1 */
34+
whiteNoiseVolume?: number;
35+
/** 切换背景音开关 */
36+
onToggleWhiteNoise?: () => void;
37+
/** 调节背景音音量 */
38+
onWhiteNoiseVolume?: (vol: number) => void;
39+
}
40+
3041
/**
3142
* 根据进度计算背景渐变色场(完全不透明,确保计时器清晰可读)
3243
* 0-20%: 深蓝宁静 (brand-700 基调)
@@ -59,7 +70,12 @@ function getBackgroundGradient(progressPercent: number): string {
5970
rgb(5, 4, 10) 100%)`;
6071
}
6172

62-
export default function ImmersiveTimer() {
73+
export default function ImmersiveTimer({
74+
whiteNoiseEnabled = false,
75+
whiteNoiseVolume = 0.5,
76+
onToggleWhiteNoise,
77+
onWhiteNoiseVolume,
78+
}: ImmersiveTimerProps) {
6379
const progressRef = useRef<SVGCircleElement>(null);
6480
const rafRef = useRef<number>(0);
6581
const prefersReduced = useReducedMotion();
@@ -217,6 +233,49 @@ export default function ImmersiveTimer() {
217233
animate={{ opacity: 1, y: 0 }}
218234
transition={{ ...SPRING.gentle, delay: 0.2 }}
219235
>
236+
{/* 背景音开关 + 音量调节 */}
237+
{onToggleWhiteNoise && (
238+
<div className="flex items-center gap-2">
239+
<motion.button
240+
whileHover={{ scale: 1.1 }}
241+
whileTap={{ scale: 0.9 }}
242+
onClick={onToggleWhiteNoise}
243+
aria-label={whiteNoiseEnabled ? '关闭背景音' : '开启背景音'}
244+
className={cn(
245+
'w-10 h-10 rounded-full flex items-center justify-center',
246+
'bg-white/5 border backdrop-blur-sm',
247+
'transition-colors duration-200',
248+
whiteNoiseEnabled
249+
? 'border-white/15 text-white/80 hover:text-white'
250+
: 'border-white/8 text-white/35 hover:text-white/60 hover:bg-white/8',
251+
)}
252+
>
253+
{whiteNoiseEnabled
254+
? <Volume2 className="w-4 h-4" strokeWidth={1.5} />
255+
: <VolumeX className="w-4 h-4" strokeWidth={1.5} />}
256+
</motion.button>
257+
<AnimatePresence>
258+
{whiteNoiseEnabled && onWhiteNoiseVolume && (
259+
<motion.input
260+
key="vol-slider"
261+
type="range"
262+
min={0}
263+
max={1}
264+
step={0.05}
265+
value={whiteNoiseVolume}
266+
onChange={(e) => onWhiteNoiseVolume(parseFloat(e.target.value))}
267+
aria-label="背景音音量"
268+
className="w-20 h-1 cursor-pointer accent-[#5B8A72]"
269+
initial={{ opacity: 0, width: 0 }}
270+
animate={{ opacity: 1, width: 80 }}
271+
exit={{ opacity: 0, width: 0 }}
272+
transition={{ duration: 0.25, ease: 'easeOut' }}
273+
/>
274+
)}
275+
</AnimatePresence>
276+
</div>
277+
)}
278+
220279
{/* 暂停/继续 */}
221280
<motion.button
222281
whileHover={{ scale: 1.1 }}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* PresetEditor — 番茄预设创建/编辑弹窗
3+
*
4+
* 表单字段:名称、图标、专注/短休/长休时长、长休间隔、静默开关。
5+
* 底部实时预览循环标记数量。
6+
*
7+
* @ai-context: 新增组件,供 PomodoroPage "+"按钮和设置页编辑按钮调用。
8+
* 图标选择网格预置 13 个学习相关 lucide 图标。
9+
* @ai-context: Preset create/edit modal with live cycle-marker preview.
10+
*/
11+
import { useState } from 'react';
12+
import { motion, AnimatePresence } from 'framer-motion';
13+
import { X, GraduationCap, BookOpen, PenLine, BookMarked, Brain, Timer, Moon, Coffee, Dumbbell, Music, Languages, Calculator, Microscope } from 'lucide-react';
14+
import { cn } from '@/lib/utils';
15+
import { Button, Input } from '@/components/ui';
16+
import CycleMarkers from './CycleMarkers';
17+
import type { PomodoroPreset } from '@/types/models';
18+
19+
/** 可选图标列表 */
20+
const ICON_OPTIONS = [
21+
{ name: 'GraduationCap', Icon: GraduationCap },
22+
{ name: 'BookOpen', Icon: BookOpen },
23+
{ name: 'PenLine', Icon: PenLine },
24+
{ name: 'BookMarked', Icon: BookMarked },
25+
{ name: 'Brain', Icon: Brain },
26+
{ name: 'Timer', Icon: Timer },
27+
{ name: 'Moon', Icon: Moon },
28+
{ name: 'Coffee', Icon: Coffee },
29+
{ name: 'Dumbbell', Icon: Dumbbell },
30+
{ name: 'Music', Icon: Music },
31+
{ name: 'Languages', Icon: Languages },
32+
{ name: 'Calculator', Icon: Calculator },
33+
{ name: 'Microscope', Icon: Microscope },
34+
] as const;
35+
36+
interface PresetEditorProps {
37+
open: boolean;
38+
onClose: () => void;
39+
onSave: (data: Omit<PomodoroPreset, 'id' | 'sortOrder' | 'createdAt' | 'builtin'>) => void;
40+
/** 编辑模式时传入已有预设 */
41+
initial?: PomodoroPreset | null;
42+
}
43+
44+
export default function PresetEditor({ open, onClose, onSave, initial }: PresetEditorProps) {
45+
const [name, setName] = useState(initial?.name ?? '');
46+
const [icon, setIcon] = useState(initial?.icon ?? 'BookOpen');
47+
const [workDuration, setWorkDuration] = useState(initial?.workDuration ?? 25);
48+
const [shortBreak, setShortBreak] = useState(initial?.shortBreakDuration ?? 5);
49+
const [longBreak, setLongBreak] = useState(initial?.longBreakDuration ?? 15);
50+
const [interval, setInterval_] = useState(initial?.longBreakInterval ?? 4);
51+
const [silent, setSilent] = useState(initial?.silent ?? false);
52+
53+
const handleSubmit = () => {
54+
if (!name.trim()) return;
55+
onSave({
56+
name: name.trim().slice(0, 8),
57+
icon,
58+
workDuration: Math.max(1, Math.min(180, workDuration)),
59+
shortBreakDuration: Math.max(1, Math.min(60, shortBreak)),
60+
longBreakDuration: Math.max(1, Math.min(60, longBreak)),
61+
longBreakInterval: Math.max(0, Math.min(12, interval)),
62+
silent,
63+
});
64+
onClose();
65+
};
66+
67+
return (
68+
<AnimatePresence>
69+
{open && (
70+
<motion.div
71+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
72+
initial={{ opacity: 0 }}
73+
animate={{ opacity: 1 }}
74+
exit={{ opacity: 0 }}
75+
>
76+
{/* 背景遮罩 */}
77+
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
78+
79+
{/* 弹窗主体 */}
80+
<motion.div
81+
className="relative w-full max-w-md bg-bg-elevated rounded-2xl border border-border/30 shadow-xl p-6 max-h-[85vh] overflow-y-auto"
82+
initial={{ scale: 0.95, y: 10 }}
83+
animate={{ scale: 1, y: 0 }}
84+
exit={{ scale: 0.95, y: 10 }}
85+
transition={{ duration: 0.2 }}
86+
>
87+
<div className="flex items-center justify-between mb-5">
88+
<h2 className="text-h3 font-semibold text-text-primary">
89+
{initial ? '编辑预设' : '新建预设'}
90+
</h2>
91+
<button onClick={onClose} className="p-1.5 rounded-full hover:bg-bg-secondary text-text-tertiary">
92+
<X className="w-4 h-4" />
93+
</button>
94+
</div>
95+
96+
<div className="space-y-4">
97+
{/* 名称 */}
98+
<Input
99+
label="预设名称"
100+
value={name}
101+
onChange={(e) => setName(e.target.value)}
102+
placeholder="如:刷题、背单词"
103+
maxLength={8}
104+
/>
105+
106+
{/* 图标选择 */}
107+
<div>
108+
<p className="text-b2 font-medium text-text-primary mb-2">图标</p>
109+
<div className="grid grid-cols-7 gap-1.5">
110+
{ICON_OPTIONS.map(({ name: iconName, Icon }) => (
111+
<button
112+
key={iconName}
113+
type="button"
114+
onClick={() => setIcon(iconName)}
115+
className={cn(
116+
'p-2 rounded-lg flex items-center justify-center transition-all',
117+
icon === iconName
118+
? 'bg-brand-500 text-white shadow-sm'
119+
: 'bg-bg-secondary hover:bg-bg-tertiary text-text-secondary',
120+
)}
121+
>
122+
<Icon className="w-4 h-4" strokeWidth={1.5} />
123+
</button>
124+
))}
125+
</div>
126+
</div>
127+
128+
{/* 时长设置 */}
129+
<div className="grid grid-cols-2 gap-3">
130+
<Input
131+
label="专注(分钟)"
132+
type="number"
133+
value={String(workDuration)}
134+
onChange={(e) => setWorkDuration(parseInt(e.target.value) || 0)}
135+
min={1}
136+
max={180}
137+
/>
138+
<Input
139+
label="短休(分钟)"
140+
type="number"
141+
value={String(shortBreak)}
142+
onChange={(e) => setShortBreak(parseInt(e.target.value) || 0)}
143+
min={1}
144+
max={60}
145+
/>
146+
<Input
147+
label="长休(分钟)"
148+
type="number"
149+
value={String(longBreak)}
150+
onChange={(e) => setLongBreak(parseInt(e.target.value) || 0)}
151+
min={1}
152+
max={60}
153+
/>
154+
<Input
155+
label="长休间隔"
156+
type="number"
157+
value={String(interval)}
158+
onChange={(e) => setInterval_(parseInt(e.target.value) || 0)}
159+
min={0}
160+
max={12}
161+
suffix={<span className="text-text-tertiary text-c1">个</span>}
162+
/>
163+
</div>
164+
<p className="text-c1 text-text-tertiary -mt-2">
165+
长休间隔 = 0 表示不设长休(类似上课模式)
166+
</p>
167+
168+
{/* 静默开关 */}
169+
<label className="flex items-center justify-between py-1 cursor-pointer">
170+
<span className="text-b2 text-text-primary">静默模式</span>
171+
<button
172+
type="button"
173+
role="switch"
174+
aria-checked={silent}
175+
onClick={() => setSilent(!silent)}
176+
className={cn(
177+
'relative w-11 h-6 rounded-full transition-colors',
178+
silent ? 'bg-brand-600' : 'bg-bg-tertiary border border-border/50',
179+
)}
180+
>
181+
<span className={cn(
182+
'absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm transition-transform',
183+
silent && 'translate-x-5',
184+
)} />
185+
</button>
186+
</label>
187+
188+
{/* 循环标记预览 */}
189+
<div className="pt-2 border-t border-border/20">
190+
<p className="text-c1 text-text-tertiary mb-2">循环标记预览</p>
191+
<CycleMarkers total={interval > 0 ? interval : 4} filled={1} />
192+
</div>
193+
</div>
194+
195+
{/* 操作按钮 */}
196+
<div className="flex gap-3 mt-6">
197+
<Button variant="secondary" className="flex-1" onClick={onClose}>取消</Button>
198+
<Button className="flex-1" onClick={handleSubmit} disabled={!name.trim()}>
199+
{initial ? '保存' : '创建'}
200+
</Button>
201+
</div>
202+
</motion.div>
203+
</motion.div>
204+
)}
205+
</AnimatePresence>
206+
);
207+
}

0 commit comments

Comments
 (0)