Skip to content

Commit 3a91f24

Browse files
committed
feat(pomodoro): 表盘与预设标签 UI 增强
- PresetTabs 横排胶囊改 4 列网格布局(生物基座能量轨道) - TimerFace 表盘细节增强;PresetEditor 精简冗余代码 - PomodoroControls 控制区优化;PomodoroPage 布局适配 - timerSlice/StoreTypes 适配新交互,同步更新单测
1 parent 45e2752 commit 3a91f24

10 files changed

Lines changed: 197 additions & 157 deletions

File tree

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

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
* 引力场布局:控件围绕生物下缘的固定半圆弧轨道排布,位置由固定角度槽位
55
* 三角函数静态计算(不随悬停漂移),平时低透明度(0.6)常驻可见,悬停浮现至 1。
66
*
7+
* 定位根治:轨道半径使用容器尺寸百分比(ORBIT_RATIO),不依赖 px 测量——
8+
* 容器尺寸任何变化(视口 vmin 波动等)按钮平滑跟随而非跳变,零测量零跳动。
9+
*
710
* 交互收敛(主交互 = 点击时间生物):
811
* - 主按钮已清除:开始/暂停/继续由生物点击承担(热启动直接迈步、专注态暂停/恢复)
9-
* - 四颗卫星:重置 / 跳过 / 沉浸 / 白噪音(弧端小图标下拉)
12+
* - 四颗卫星:重置 / 跳过(统一 skipStage)/ 沉浸 / 白噪音(弧端小图标下拉)
1013
*
11-
* @ai-context: 状态取自 store;唯一外部依赖是 orbitRadius(父组件按生物尺寸传入)。
14+
* @ai-context: 状态取自 store;定位由百分比驱动,无外部尺寸依赖。
1215
*/
1316
import { useState, useRef, useEffect } from 'react';
1417
import { motion, AnimatePresence } from 'framer-motion';
@@ -20,25 +23,23 @@ import { useAudioPrefsStore } from '@/lib/audio/audioPrefsStore';
2023
import { audioTracks } from '@/lib/audio/audioConfig';
2124
import { useEstimatedVolume } from '../hooks/useEstimatedVolume';
2225

23-
interface PomodoroControlsProps {
24-
/** 卫星轨道半径(px)= 生物半径 × 1.45,父组件按生物尺寸计算 */
25-
orbitRadius: number;
26-
}
27-
2826
/** 固定角度槽位(度):弧顶 90° 让位给生物主交互,四卫星对称分布于两侧弧 */
2927
const SAT = {
3028
reset: 135, skip: 45, immersive: 165, noise: 15,
3129
} as const;
3230

31+
/** 轨道半径比例:容器尺寸的 72.5%(= 半宽 × 1.45),百分比定位随容器平滑跟随 */
32+
const ORBIT_RATIO = 0.725;
33+
3334
/** 卫星平时透明度(常驻可见,悬停浮现至 1) */
3435
const SAT_IDLE_OPACITY = 0.6;
3536

36-
/** 角度 → 绝对定位(圆心 = 容器中心,混合 calc 保持响应式) */
37-
function satPos(angleDeg: number, radius: number): React.CSSProperties {
37+
/** 角度 → 百分比定位(圆心 = 容器中心;百分比相对容器,任何尺寸变化平滑跟随) */
38+
function satPos(angleDeg: number): React.CSSProperties {
3839
const rad = (angleDeg * Math.PI) / 180;
3940
return {
40-
left: `calc(50% + ${Math.cos(rad) * radius}px)`,
41-
top: `calc(50% + ${Math.sin(rad) * radius}px)`,
41+
left: `calc(50% + ${(Math.cos(rad) * ORBIT_RATIO * 100).toFixed(3)}%)`,
42+
top: `calc(50% + ${(Math.sin(rad) * ORBIT_RATIO * 100).toFixed(3)}%)`,
4243
};
4344
}
4445

@@ -47,14 +48,14 @@ function satClass(...classes: string[]): string {
4748
return cn('absolute -translate-x-1/2 -translate-y-1/2 flex items-center justify-center', ...classes);
4849
}
4950

50-
export function PomodoroControls({ orbitRadius }: PomodoroControlsProps) {
51+
export function PomodoroControls() {
5152
// P0-1 细粒度订阅:整 store 订阅会在任何字段变化时重渲染卫星控制区
5253
const isRunning = usePomodoroStore((s) => s.isRunning);
5354
const isPaused = usePomodoroStore((s) => s.isPaused);
5455
const isArmed = usePomodoroStore((s) => s.isArmed);
5556
// 动作(稳定引用)
5657
const reset = usePomodoroStore((s) => s.reset);
57-
const skip = usePomodoroStore((s) => s.skip);
58+
const skipStage = usePomodoroStore((s) => s.skipStage);
5859
const enterImmersive = usePomodoroStore((s) => s.enterImmersive);
5960
// 沉睡态(未激活未运行):沉浸入口常驻但禁用(先开始专注才能进入沉浸)
6061
const isAsleep = !isRunning && !isPaused && !isArmed;
@@ -85,9 +86,9 @@ export function PomodoroControls({ orbitRadius }: PomodoroControlsProps) {
8586
}, [trackPickerOpen]);
8687

8788
return (
88-
<div className="pointer-events-none">
89+
<>
8990
{/* ── 白噪音卫星(弧右下)── */}
90-
<div ref={pickerRef} className="pointer-events-auto absolute z-10" style={satPos(SAT.noise, orbitRadius)}>
91+
<div ref={pickerRef} className="pointer-events-auto absolute z-10" style={satPos(SAT.noise)}>
9192
<Tip text={whiteNoiseEnabled ? '关闭背景音' : '开启背景音'}>
9293
<motion.button
9394
whileTap={{ scale: 0.9 }}
@@ -148,10 +149,10 @@ export function PomodoroControls({ orbitRadius }: PomodoroControlsProps) {
148149
</div>
149150

150151
{/* ── 重置卫星(弧左下)── */}
151-
<div className="pointer-events-auto absolute z-10" style={satPos(SAT.reset, orbitRadius)}>
152+
<div className="pointer-events-auto absolute z-10" style={satPos(SAT.reset)}>
152153
<Tip text="重置计时器(回沉睡)">
153154
<motion.button
154-
whileTap={{ scale: 0.9, rotate: -180 }}
155+
whileTap={{ scale: 0.9 }}
155156
onClick={reset}
156157
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')}
157158
animate={{ opacity: SAT_IDLE_OPACITY }}
@@ -162,12 +163,12 @@ export function PomodoroControls({ orbitRadius }: PomodoroControlsProps) {
162163
</Tip>
163164
</div>
164165

165-
{/* ── 跳过卫星(弧右下)── */}
166-
<div className="pointer-events-auto absolute z-10" style={satPos(SAT.skip, orbitRadius)}>
166+
{/* ── 跳过卫星(弧右下):统一 skipStage(呼吸态→跳过呼吸进专注,其他→常规跳过)── */}
167+
<div className="pointer-events-auto absolute z-10" style={satPos(SAT.skip)}>
167168
<Tip text="跳过当前阶段">
168169
<motion.button
169-
whileTap={{ scale: 0.9, x: 3 }}
170-
onClick={skip}
170+
whileTap={{ scale: 0.9 }}
171+
onClick={skipStage}
171172
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')}
172173
animate={{ opacity: SAT_IDLE_OPACITY }}
173174
whileHover={{ opacity: 1 }}
@@ -178,7 +179,7 @@ export function PomodoroControls({ orbitRadius }: PomodoroControlsProps) {
178179
</div>
179180

180181
{/* ── 沉浸卫星(弧更左下,常驻显示;沉睡态禁用,先开始专注才能进入沉浸)── */}
181-
<div className="pointer-events-auto absolute z-10" style={satPos(SAT.immersive, orbitRadius)}>
182+
<div className="pointer-events-auto absolute z-10" style={satPos(SAT.immersive)}>
182183
<Tip text={isAsleep ? '先开始专注(点击时间生物)' : '进入专注模式'}>
183184
<motion.button
184185
whileTap={isAsleep ? undefined : { scale: 0.9 }}
@@ -197,6 +198,6 @@ export function PomodoroControls({ orbitRadius }: PomodoroControlsProps) {
197198
</motion.button>
198199
</Tip>
199200
</div>
200-
</div>
201+
</>
201202
);
202203
}

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { X, GraduationCap, BookOpen, PenLine, BookMarked, Brain, Timer, Moon, Co
1414
import { cn } from '@/lib/utils';
1515
import { Tip } from '@/components/ui/Tip';
1616
import { Button, Input } from '@/components/ui';
17-
import CycleMarkers from './CycleMarkers';
1817
import { MOOD_LABELS, type Mood } from './chronos/particleMorphs';
1918
import type { PomodoroPreset } from '@/types/models';
2019

@@ -231,11 +230,6 @@ export default function PresetEditor({ open, onClose, onSave, initial }: PresetE
231230
<p className="text-c1 text-text-tertiary mt-1">决定时间生物粒子的外形与运动风格(上课/自习内置预设已预置)</p>
232231
</div>
233232

234-
{/* 循环标记预览 */}
235-
<div className="pt-2 border-t border-border/20">
236-
<p className="text-c1 text-text-tertiary mb-2">循环标记预览</p>
237-
<CycleMarkers total={interval > 0 ? interval : 4} filled={1} />
238-
</div>
239233
</div>
240234

241235
{/* 操作按钮 */}

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

Lines changed: 62 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -51,67 +51,72 @@ export default memo(function PresetTabs({ presets, activePresetId, canCreate, on
5151
return (
5252
<>
5353
<motion.div
54-
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"
54+
className="flex flex-col items-center gap-1 p-1.5 bg-bg-secondary/60 backdrop-blur-sm rounded-xl border border-border/20 max-w-full"
5555
initial={{ opacity: 0, y: -10 }}
5656
animate={{ opacity: 1, y: 0 }}
5757
transition={{ delay: 0.1, ...SPRING.gentle }}
5858
>
59-
{presets.map((preset) => {
60-
const Icon = PRESET_ICONS[preset.icon] ?? BookOpen;
61-
const isActive = activePresetId === preset.id;
62-
return (
63-
<motion.button
64-
key={preset.id}
65-
onClick={() => onSelect(preset.id)}
66-
onContextMenu={(e) => handleContextMenu(e, preset)}
67-
whileTap={{ scale: 0.97 }}
68-
className={cn(
69-
'relative flex items-center gap-1.5 px-4 py-2 rounded-full text-[13px] font-medium transition-all duration-300 whitespace-nowrap',
70-
isActive
71-
? 'text-white shadow-[0_2px_12px_rgba(91,138,114,0.3)]'
72-
: 'text-text-secondary hover:text-text-primary',
73-
)}
74-
>
75-
{isActive && (
76-
<motion.div
77-
layoutId="pomo-mode-bg"
78-
className="absolute inset-0 rounded-full bg-brand-500"
79-
transition={SPRING.default}
80-
/>
81-
)}
82-
<span className="relative flex items-center gap-1.5">
83-
<Icon className="w-4 h-4" strokeWidth={1.5} />
84-
{preset.name}
85-
<span className={cn('text-[10px] opacity-60', isActive && 'opacity-80')}>· {preset.workDuration}min</span>
86-
</span>
87-
</motion.button>
88-
);
89-
})}
90-
{/* "+" 快捷创建预设入口,带 tooltip(达上限时隐藏) */}
91-
{canCreate && (
92-
<Tip text="新建预设">
93-
<motion.button
94-
onClick={onCreate}
95-
whileTap={{ scale: 0.9 }}
96-
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"
97-
aria-label="新建预设"
98-
>
99-
<Plus className="w-4 h-4" strokeWidth={1.5} />
100-
</motion.button>
101-
</Tip>
102-
)}
103-
{/* 预设管理入口(删除/排序/编辑在深潜设置页),带 tooltip */}
104-
{onManage && (
105-
<Tip text="预设管理(删除 / 排序)">
106-
<motion.button
107-
onClick={onManage}
108-
whileTap={{ scale: 0.9 }}
109-
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"
110-
aria-label="预设管理"
111-
>
112-
<SlidersHorizontal className="w-4 h-4" strokeWidth={1.5} />
113-
</motion.button>
114-
</Tip>
59+
<div className="grid grid-cols-4 gap-1 w-full max-w-[480px]">
60+
{presets.map((preset) => {
61+
const Icon = PRESET_ICONS[preset.icon] ?? BookOpen;
62+
const isActive = activePresetId === preset.id;
63+
return (
64+
<motion.button
65+
key={preset.id}
66+
onClick={() => onSelect(preset.id)}
67+
onContextMenu={(e) => handleContextMenu(e, preset)}
68+
whileTap={{ scale: 0.97 }}
69+
className={cn(
70+
'relative flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-full text-[11px] font-medium transition-all duration-300 whitespace-nowrap min-w-0',
71+
isActive
72+
? 'text-white shadow-[0_2px_12px_rgba(91,138,114,0.3)]'
73+
: 'text-text-secondary hover:text-text-primary',
74+
)}
75+
>
76+
{isActive && (
77+
<motion.div
78+
layoutId="pomo-mode-bg"
79+
className="absolute inset-0 rounded-full bg-brand-500"
80+
transition={SPRING.default}
81+
/>
82+
)}
83+
<span className="relative flex items-center gap-1 truncate">
84+
<Icon className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
85+
<span className="truncate">{preset.name}</span>
86+
<span className={cn('text-[9px] opacity-60 shrink-0', isActive && 'opacity-80')}>{preset.workDuration}min</span>
87+
</span>
88+
</motion.button>
89+
);
90+
})}
91+
</div>
92+
{/* 操作按钮行 */}
93+
{(canCreate || onManage) && (
94+
<div className="flex items-center gap-1.5">
95+
{canCreate && (
96+
<Tip text="新建预设">
97+
<motion.button
98+
onClick={onCreate}
99+
whileTap={{ scale: 0.9 }}
100+
className="flex items-center justify-center w-7 h-7 rounded-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary/60 transition-all duration-200"
101+
aria-label="新建预设"
102+
>
103+
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
104+
</motion.button>
105+
</Tip>
106+
)}
107+
{onManage && (
108+
<Tip text="预设管理(删除 / 排序)">
109+
<motion.button
110+
onClick={onManage}
111+
whileTap={{ scale: 0.9 }}
112+
className="flex items-center justify-center w-7 h-7 rounded-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary/60 transition-all duration-200"
113+
aria-label="预设管理"
114+
>
115+
<SlidersHorizontal className="w-3.5 h-3.5" strokeWidth={1.5} />
116+
</motion.button>
117+
</Tip>
118+
)}
119+
</div>
115120
)}
116121
</motion.div>
117122
{/* 右键菜单 */}

0 commit comments

Comments
 (0)