Skip to content

Commit 63aa17f

Browse files
committed
Merge branch 'dev': 番茄钟预设自定义、目标跳过修复与仪表盘卡片跳转
2 parents 9e838db + 35ac62e commit 63aa17f

16 files changed

Lines changed: 1150 additions & 161 deletions

‎client/src/features/dashboard/components/KnowledgePreviewCard.tsx‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
* @ai-context: 通用组件:KnowledgePreviewCard。
88
*/
99
import { useRef } from 'react';
10+
import { useNavigate } from 'react-router-dom';
1011
import { motion, useMotionValue, useTransform, useSpring } from 'framer-motion';
11-
import { FileText, Layers, Timer, Lightbulb } from 'lucide-react';
12+
import { FileText, Layers, Timer, Lightbulb, ArrowUpRight } from 'lucide-react';
1213
import { cn } from '@/lib/utils';
1314
import { SPRING } from '@/lib/animation/springConfig';
1415

@@ -20,6 +21,8 @@ interface KnowledgeCard {
2021
title: string;
2122
excerpt?: string;
2223
time: string;
24+
/** 点击跳转目标路径 */
25+
targetPath?: string;
2326
}
2427

2528
interface KnowledgePreviewCardProps {
@@ -70,9 +73,19 @@ const asymmetricRadius = [
7073

7174
export default function KnowledgePreviewCard({ card, index }: KnowledgePreviewCardProps) {
7275
const ref = useRef<HTMLDivElement>(null);
76+
const navigate = useNavigate();
7377
const mouseX = useMotionValue(0);
7478
const mouseY = useMotionValue(0);
7579

80+
const clickable = !!card.targetPath;
81+
const handleClick = () => { if (card.targetPath) navigate(card.targetPath); };
82+
const handleKeyDown = (e: React.KeyboardEvent) => {
83+
if ((e.key === 'Enter' || e.key === ' ') && card.targetPath) {
84+
e.preventDefault();
85+
navigate(card.targetPath);
86+
}
87+
};
88+
7689
const rotateX = useSpring(
7790
useTransform(mouseY, [-0.5, 0.5], [8, -8]),
7891
{ stiffness: SPRING.default.stiffness, damping: SPRING.default.damping }
@@ -101,8 +114,12 @@ export default function KnowledgePreviewCard({ card, index }: KnowledgePreviewCa
101114
return (
102115
<motion.div
103116
ref={ref}
117+
role={clickable ? 'link' : undefined}
118+
tabIndex={clickable ? 0 : undefined}
119+
aria-label={clickable ? `打开${config.label}:${card.title}` : undefined}
104120
className={cn(
105-
'relative overflow-hidden cursor-default group',
121+
'relative overflow-hidden group focus-visible:outline-2 focus-visible:outline-brand-500',
122+
clickable ? 'cursor-pointer' : 'cursor-default',
106123
'border border-border/20 backdrop-blur-sm',
107124
'bg-bg-elevated/40 hover:bg-bg-elevated/60',
108125
'transition-colors duration-beat-x3',
@@ -116,7 +133,10 @@ export default function KnowledgePreviewCard({ card, index }: KnowledgePreviewCa
116133
}}
117134
onMouseMove={handleMouseMove}
118135
onMouseLeave={handleMouseLeave}
136+
onClick={handleClick}
137+
onKeyDown={handleKeyDown}
119138
whileHover={{ y: -4, scale: 1.02 }}
139+
whileTap={clickable ? { scale: 0.97 } : undefined}
120140
transition={SPRING.default}
121141
>
122142
{/* 交融渐变遮罩 */}
@@ -142,7 +162,12 @@ export default function KnowledgePreviewCard({ card, index }: KnowledgePreviewCa
142162
<div className="flex items-center gap-1.5">
143163
<Icon className={cn('w-3.5 h-3.5', config.accentColor)} strokeWidth={1.5} />
144164
<span className={cn('text-c2 font-medium', config.accentColor)}>{config.label}</span>
145-
<span className="text-c2 text-text-tertiary ml-auto">{card.time}</span>
165+
<span className="text-c2 text-text-tertiary ml-auto flex items-center gap-1">
166+
{card.time}
167+
{clickable && (
168+
<ArrowUpRight className="w-3 h-3 opacity-0 group-hover:opacity-70 transition-opacity duration-beat-x2" />
169+
)}
170+
</span>
146171
</div>
147172

148173
{/* 标题 */}

‎client/src/features/dashboard/pages/DashboardPage.tsx‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,10 @@ export default function DashboardPage() {
309309
}, [showRitual, lastSession]);
310310

311311
const heroStats = [
312-
{ label: '今日专注', value: todayPomodoroCount, unit: '次', accent: 'pomodoro', icon: Timer },
313-
{ label: '笔记总数', value: noteTotal, unit: '篇', accent: 'note', icon: FileText },
314-
{ label: '待复习', value: dueFlashcardCount, unit: '张', accent: 'flashcard', icon: Layers },
315-
{ label: '费曼进行中', value: feynmanInProgressCount, unit: '个', accent: 'feynman', icon: Lightbulb },
312+
{ label: '今日专注', value: todayPomodoroCount, unit: '次', accent: 'pomodoro', icon: Timer, path: '/pomodoro' },
313+
{ label: '笔记总数', value: noteTotal, unit: '篇', accent: 'note', icon: FileText, path: '/notes' },
314+
{ label: '待复习', value: dueFlashcardCount, unit: '张', accent: 'flashcard', icon: Layers, path: '/flashcards' },
315+
{ label: '费曼进行中', value: feynmanInProgressCount, unit: '个', accent: 'feynman', icon: Lightbulb, path: '/feynman' },
316316
];
317317

318318
/* ── 学习分析 ── */
@@ -332,6 +332,7 @@ export default function DashboardPage() {
332332
type: 'note',
333333
title: n.title || '无标题笔记',
334334
time: formatRelativeTime(new Date(n.updatedAt)),
335+
targetPath: `/notes/${n.id}`,
335336
});
336337
});
337338

@@ -347,6 +348,7 @@ export default function DashboardPage() {
347348
type: 'flashcard',
348349
title: card ? card.front.replace(/<[^>]*>/g, '').slice(0, 40) : '复习闪卡',
349350
time: formatRelativeTime(new Date(latestReview.reviewedAt)),
351+
targetPath: '/flashcards',
350352
});
351353
}
352354

@@ -361,6 +363,7 @@ export default function DashboardPage() {
361363
type: 'pomodoro',
362364
title: `${Math.round(latest.actualDuration / 60)} 分钟专注`,
363365
time: formatRelativeTime(new Date(latest.completedAt)),
366+
targetPath: '/pomodoro',
364367
});
365368
}
366369

@@ -374,6 +377,7 @@ export default function DashboardPage() {
374377
type: 'feynman',
375378
title: sortedFeynman[0].concept,
376379
time: formatRelativeTime(new Date(sortedFeynman[0].updatedAt)),
380+
targetPath: `/feynman/${sortedFeynman[0].id}`,
377381
});
378382
}
379383

@@ -420,14 +424,21 @@ export default function DashboardPage() {
420424
return (
421425
<motion.div
422426
key={stat.label}
427+
role="link"
428+
tabIndex={0}
429+
aria-label={`进入${stat.label}模块`}
430+
onClick={() => navigate(stat.path)}
431+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(stat.path); } }}
423432
className={cn(
424-
'relative p-5 rounded-kb-xl',
433+
'relative p-5 rounded-kb-xl cursor-pointer',
425434
'border border-border/15 backdrop-blur-sm',
426435
'bg-bg-elevated/30 hover:bg-bg-elevated/50',
427436
'transition-all duration-beat-x3 group',
437+
'focus-visible:outline-2 focus-visible:outline-brand-500',
428438
)}
429439
variants={heroStatVariant}
430440
whileHover={{ y: -2, scale: 1.02 }}
441+
whileTap={{ scale: 0.97 }}
431442
transition={SPRING.default}
432443
>
433444
<div className="flex items-center gap-2 mb-3">
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+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @ai-context: GoalInput 回归测试——"跳过"与空输入点"开始"都必须启动计时
3+
* (提交空目标),只有真正取消(Modal 关闭按钮)才不启动。
4+
* @ai-context: Regression test — "skip" and empty-input "start" must both
5+
* launch the timer with an empty goal; only an explicit close cancels.
6+
*/
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import { render, screen, fireEvent } from '@testing-library/react';
9+
import GoalInput from './GoalInput';
10+
11+
// GoalMemory 读取 Dexie,测试中隔离掉真实数据库
12+
vi.mock('@/lib/storage', () => ({
13+
db: {
14+
pomodoroGoals: {
15+
orderBy: () => ({
16+
reverse: () => ({
17+
limit: () => ({ toArray: () => Promise.resolve([]) }),
18+
}),
19+
}),
20+
},
21+
},
22+
}));
23+
24+
// Modal 播放开关音效,测试中无需真实音频
25+
vi.mock('@/lib/audio/SoundPlayer', () => ({
26+
soundPlayer: { play: vi.fn() },
27+
}));
28+
29+
describe('GoalInput - 空目标番茄钟', () => {
30+
const onSubmit = vi.fn();
31+
const onClose = vi.fn();
32+
33+
function renderGoalInput() {
34+
return render(
35+
<GoalInput
36+
open
37+
onClose={onClose}
38+
onSubmit={onSubmit}
39+
rememberGoal={false}
40+
onRememberChange={vi.fn()}
41+
/>,
42+
);
43+
}
44+
45+
beforeEach(() => {
46+
onSubmit.mockClear();
47+
onClose.mockClear();
48+
});
49+
50+
it('点击"跳过"应提交空目标以启动计时,而非仅关闭弹窗', () => {
51+
renderGoalInput();
52+
53+
fireEvent.click(screen.getByRole('button', { name: '跳过' }));
54+
55+
// 核心断言:跳过 = 无目标但开始计时
56+
expect(onSubmit).toHaveBeenCalledWith('');
57+
});
58+
59+
it('输入为空时点击"开始"也应启动计时', () => {
60+
renderGoalInput();
61+
62+
fireEvent.click(screen.getByRole('button', { name: '开始' }));
63+
64+
expect(onSubmit).toHaveBeenCalledWith('');
65+
});
66+
67+
it('输入为空时按 Enter 也应启动计时', () => {
68+
renderGoalInput();
69+
70+
fireEvent.keyDown(screen.getByPlaceholderText('这个番茄要做什么?'), { key: 'Enter' });
71+
72+
expect(onSubmit).toHaveBeenCalledWith('');
73+
});
74+
75+
it('有目标时点击"开始"提交去空格后的目标', () => {
76+
renderGoalInput();
77+
78+
fireEvent.change(screen.getByPlaceholderText('这个番茄要做什么?'), {
79+
target: { value: ' 背单词 ' },
80+
});
81+
fireEvent.click(screen.getByRole('button', { name: '开始' }));
82+
83+
expect(onSubmit).toHaveBeenCalledWith('背单词');
84+
});
85+
86+
it('点击"取消"不提交、不启动计时', () => {
87+
renderGoalInput();
88+
89+
fireEvent.click(screen.getByRole('button', { name: '取消' }));
90+
91+
expect(onSubmit).not.toHaveBeenCalled();
92+
expect(onClose).toHaveBeenCalled();
93+
});
94+
95+
it('点击关闭按钮也是取消:不提交、不启动计时', () => {
96+
renderGoalInput();
97+
98+
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
99+
100+
expect(onSubmit).not.toHaveBeenCalled();
101+
expect(onClose).toHaveBeenCalled();
102+
});
103+
});

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
/**
22
* @ai-context: 通用组件:GoalInput。
3+
* @ai-context: “跳过”与“取消”是两种意图:跳过 = 不设目标但开始计时
4+
* (onSubmit('')),取消 = 关闭弹窗不开始(onClose,仅 Modal 关闭按钮)。
5+
* 两者共用同一回调会使空目标番茄钟无法启动(内测反馈 bug)。
6+
* @ai-context: "Skip" and "cancel" are distinct intents: skip starts the timer
7+
* with an empty goal; cancel closes the dialog without starting.
38
*/
49
import { useState, useRef, useEffect } from 'react';
510
import { Modal } from '@/components/ui/Modal';
@@ -8,7 +13,9 @@ import GoalMemory from './GoalMemory';
813

914
interface GoalInputProps {
1015
open: boolean;
16+
/** 取消:关闭弹窗且不开始计时 */
1117
onClose: () => void;
18+
/** 提交并开始计时;空字符串表示本次番茄不设目标 */
1219
onSubmit: (goal: string) => void;
1320
rememberGoal: boolean;
1421
onRememberChange: (v: boolean) => void;
@@ -32,14 +39,9 @@ export default function GoalInput({
3239
}
3340
}, [open]);
3441

42+
/** 提交目标并开始;输入为空时等同于“跳过”(无目标但仍然开始计时) */
3543
const handleSubmit = () => {
36-
const trimmed = text.trim();
37-
if (trimmed) {
38-
onSubmit(trimmed);
39-
} else {
40-
// 空内容时等同于跳过
41-
onClose();
42-
}
44+
onSubmit(text.trim());
4345
};
4446

4547
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -63,7 +65,12 @@ export default function GoalInput({
6365
size="sm"
6466
footer={
6567
<>
68+
{/* 取消:关闭弹窗,不开始番茄 */}
6669
<Button variant="ghost" size="md" onClick={onClose}>
70+
取消
71+
</Button>
72+
{/* 跳过:不设目标,但仍然开始计时(用 secondary 与“取消”在视觉上拉开差异) */}
73+
<Button variant="secondary" size="md" onClick={() => onSubmit('')}>
6774
跳过
6875
</Button>
6976
<Button variant="primary" size="md" onClick={handleSubmit}>

0 commit comments

Comments
 (0)