Skip to content

Commit 030ac80

Browse files
committed
feat(onboarding): First Dive 新手引导系统与新手期双标签导航
新增新手引导流程(onboarding feature + OnboardingOverlay),并调整 侧边栏/布局与 3D 导航实体以适配新手期双标签导航策略。
1 parent a3a4e18 commit 030ac80

19 files changed

Lines changed: 1087 additions & 11 deletions

‎client/src/components/layout/AppLayout.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { HelpCenter } from '@/components/onboarding/HelpCenter';
2323
import { useOnboardingStore } from '@/components/onboarding/useOnboardingStore';
2424
import { useRuntimeEnv } from '@/lib/env/useRuntimeEnv';
2525
import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
26+
import { FirstDiveGate } from '@/features/onboarding/firstDive/FirstDiveGate';
2627

2728
export default function AppLayout() {
2829
const { pathname } = useLocation();
@@ -164,6 +165,7 @@ export default function AppLayout() {
164165
<BottomNav />
165166

166167
{/* 全局组件 */}
168+
<FirstDiveGate />
167169
<OnboardingOverlay />
168170
<ModuleTourToast moduleId={currentModule} />
169171
<HelpCenter />

‎client/src/components/layout/Sidebar.tsx‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,26 @@ import { useAuth } from '@/lib/auth/AuthContext';
1616
import { useTheme } from '@/hooks/useTheme';
1717
import { SPRING, BEAT } from '@/lib/animation/springConfig';
1818
import { soundPlayer } from '@/lib/audio/SoundPlayer';
19+
import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore';
1920
import FeedbackPanel from './FeedbackPanel';
2021

2122
/* ── 导航配置 ── */
23+
// subtitle:新手期双标签副标题(隐喻名无法自解释,首潜完成前附直白说明)
2224
const navSection1 = [
2325
{ to: '/', label: '首页', icon: Home, shortcut: '⌘ 1', exact: true },
2426
];
2527

2628
const navSection2 = [
27-
{ to: '/pomodoro', label: '深潜', icon: Timer, shortcut: '⌘ 2', dotColor: 'bg-brand-500' },
28-
{ to: '/notes', label: '结礁', icon: FileText, shortcut: '⌘ 3', dotColor: 'bg-note' },
29-
{ to: '/flashcards', label: '反衰减呼吸', icon: Layers, shortcut: '⌘ 4', dotColor: 'bg-flashcard' },
30-
{ to: '/feynman', label: '浮出水面', icon: Lightbulb, shortcut: '⌘ 5', dotColor: 'bg-feynman' },
29+
{ to: '/pomodoro', label: '深潜', subtitle: '专注番茄钟', icon: Timer, shortcut: '⌘ 2', dotColor: 'bg-brand-500' },
30+
{ to: '/notes', label: '结礁', subtitle: '学习笔记', icon: FileText, shortcut: '⌘ 3', dotColor: 'bg-note' },
31+
{ to: '/flashcards', label: '反衰减呼吸', subtitle: '记忆闪卡', icon: Layers, shortcut: '⌘ 4', dotColor: 'bg-flashcard' },
32+
{ to: '/feynman', label: '浮出水面', subtitle: '费曼讲解', icon: Lightbulb, shortcut: '⌘ 5', dotColor: 'bg-feynman' },
3133
];
3234

3335
const navSection3 = [
3436
{ to: '/analytics', label: '数据分析', icon: BarChart3, shortcut: '⌘ 6' },
35-
{ to: '/inspiration', label: '萤火海沟', icon: Sparkles, shortcut: '⌘ 6' },
36-
{ to: '/classroom', label: '回声定位', icon: Clapperboard, shortcut: '⌘ 7', dotColor: 'bg-classroom' },
37+
{ to: '/inspiration', label: '萤火海沟', subtitle: '灵感收集', icon: Sparkles, shortcut: '⌘ 6' },
38+
{ to: '/classroom', label: '回声定位', subtitle: '课堂采集', icon: Clapperboard, shortcut: '⌘ 7', dotColor: 'bg-classroom' },
3739
];
3840

3941
/* ── 蔡格尼克效应:待继续任务提示池 ── */
@@ -73,6 +75,8 @@ export default function Sidebar() {
7375
const setCaptureOpen = useCaptureStore((s) => s.setOpen);
7476
const { user, isAuthenticated } = useAuth();
7577
const { theme, toggleTheme } = useTheme();
78+
// 新手期显示模块副标题(首潜完成后自动隐去)
79+
const isNewbie = useIsNewbiePhase();
7680

7781
// TODO: 接入真实学习进度数据
7882
const _progressItems: { subject: string; progress: number }[] = [];
@@ -194,9 +198,10 @@ export default function Sidebar() {
194198
</motion.div>
195199
)}
196200
{collapsed && <div className="my-1 mx-1.5 border-t border-border/20" />}
197-
{navSection2.map(({ to, label, icon: Icon, shortcut, dotColor }, i) => (
201+
{navSection2.map(({ to, label, subtitle, icon: Icon, shortcut, dotColor }, i) => (
198202
<SidebarItem
199203
key={to} to={to} label={label} icon={Icon}
204+
subtitle={isNewbie ? subtitle : undefined}
200205
shortcut={collapsed ? undefined : shortcut}
201206
dotColor={dotColor} collapsed={collapsed} index={i + 1}
202207
/>
@@ -209,9 +214,10 @@ export default function Sidebar() {
209214
</div>
210215
)}
211216
{collapsed && <div className="my-1 mx-1.5 border-t border-border/20" />}
212-
{navSection3.map(({ to, label, icon: Icon, shortcut, dotColor }, i) => (
217+
{navSection3.map(({ to, label, subtitle, icon: Icon, shortcut, dotColor }, i) => (
213218
<SidebarItem
214219
key={to} to={to} label={label} icon={Icon}
220+
subtitle={isNewbie ? subtitle : undefined}
215221
shortcut={collapsed ? undefined : shortcut}
216222
dotColor={dotColor} collapsed={collapsed} index={i + 6}
217223
/>
@@ -316,6 +322,8 @@ export default function Sidebar() {
316322
interface SidebarItemProps {
317323
to: string;
318324
label: string;
325+
/** 新手期双标签副标题(如"专注番茄钟"),传入即显示 */
326+
subtitle?: string;
319327
icon: React.ComponentType<{ className?: string; strokeWidth?: string | number }>;
320328
shortcut?: string;
321329
dotColor?: string;
@@ -324,7 +332,7 @@ interface SidebarItemProps {
324332
index?: number;
325333
}
326334

327-
function SidebarItem({ to, label, icon: Icon, shortcut, dotColor, collapsed, end, index = 0 }: SidebarItemProps) {
335+
function SidebarItem({ to, label, subtitle, icon: Icon, shortcut, dotColor, collapsed, end, index = 0 }: SidebarItemProps) {
328336
const location = useLocation();
329337
// 切换到不同模块时播放导航音效(点击当前已激活项不响)
330338
const isCurrentActive = end ? location.pathname === to : location.pathname === to || location.pathname.startsWith(to + '/');
@@ -383,6 +391,9 @@ function SidebarItem({ to, label, icon: Icon, shortcut, dotColor, collapsed, end
383391
isActive ? 'opacity-100' : 'opacity-60 group-hover:opacity-100',
384392
)}>
385393
{label}
394+
{subtitle && (
395+
<span className="ml-1.5 text-[11px] text-text-tertiary/70">· {subtitle}</span>
396+
)}
386397
</span>
387398
)}
388399
{/* 快捷键提示 — hover 显示 */}

‎client/src/components/onboarding/OnboardingOverlay.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { useEffect, useCallback } from 'react';
77
import { AnimatePresence } from 'framer-motion';
88
import { useOnboardingStore, isGuideDone } from './useOnboardingStore';
9+
import { loadFirstDiveState } from '@/features/onboarding/firstDive/firstDiveStorage';
910
import { Step1Welcome } from './steps/Step1Welcome';
1011
import { Step2Navigate } from './steps/Step2Navigate';
1112
import { Step3CameraFlight } from './steps/Step3CameraFlight';
@@ -19,7 +20,10 @@ export function OnboardingOverlay() {
1920
useOnboardingStore();
2021

2122
// 初始化:检查 localStorage,若未完成则 1.5s 后自动启动
23+
// 首潜(L0/L1)未结束时不自动启动,避免双引导叠加轰炸新用户
2224
useEffect(() => {
25+
const diveStage = loadFirstDiveState().stage;
26+
if (diveStage === 'landing' || diveStage === 'diving') return;
2327
if (!isGuideDone()) {
2428
const timer = setTimeout(() => startGuide(), 1500);
2529
return () => clearTimeout(timer);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* 首潜编排 Gate — 按引导阶段挂载 L0/L1(AppLayout 全局挂载一次)
3+
*
4+
* @ai-context: bootstrap 负责旧标记迁移、老用户判定与手册种子(幂等);
5+
* stage 为 done/skipped 时本组件渲染 null,对存量用户零打扰。
6+
* 最后一步的 praise 需要在 stage 变为 done 后仍短暂展示,故 diving 判断
7+
* 额外放行 justCompleted 存在的瞬间。
8+
*/
9+
import { useEffect } from 'react';
10+
import { AnimatePresence } from 'framer-motion';
11+
import { LandingQuestion } from './LandingQuestion';
12+
import { FirstDiveGuide } from './FirstDiveGuide';
13+
import { useFirstDiveStore } from './useFirstDiveStore';
14+
15+
export function FirstDiveGate() {
16+
const stage = useFirstDiveStore((s) => s.stage);
17+
const isReady = useFirstDiveStore((s) => s.isReady);
18+
const justCompleted = useFirstDiveStore((s) => s.justCompleted);
19+
const bootstrap = useFirstDiveStore((s) => s.bootstrap);
20+
21+
useEffect(() => {
22+
bootstrap().catch(() => {});
23+
}, [bootstrap]);
24+
25+
if (!isReady) return null;
26+
27+
const showLanding = stage === 'landing';
28+
// done 后放行片刻,让最后一句 praise 说完
29+
const showGuide = stage === 'diving' || (stage === 'done' && justCompleted !== null);
30+
31+
return (
32+
<AnimatePresence>
33+
{showLanding && <LandingQuestion key="landing" />}
34+
{showGuide && <FirstDiveGuide key="guide" />}
35+
</AnimatePresence>
36+
);
37+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* 首潜引导条(L1)— 底部常驻微光伴航,带做完整学习循环
3+
*
4+
* @ai-context: 完成检测 = 轮询 checkProgress(数据基线差值),与各模块
5+
* UI 零耦合;轮询仅在 diving 阶段挂载(3s 间隔 + 窗口聚焦触发)。
6+
* 跳过用可见按钮而非 Esc——Esc 已被 AppLayout 用于退出模块,不抢占。
7+
*/
8+
import { useEffect, useMemo, useState } from 'react';
9+
import { useLocation, useNavigate } from 'react-router-dom';
10+
import { AnimatePresence, motion } from 'framer-motion';
11+
import { ArrowRight, Play } from 'lucide-react';
12+
import { cn } from '@/lib/utils';
13+
import { usePomodoroStore } from '@/features/pomodoro/store/usePomodoroStore';
14+
import { DIVE_STEPS, getDiveStep, orderStepsByProfile } from './diveSteps';
15+
import { getCurrentStep, useFirstDiveStore } from './useFirstDiveStore';
16+
import { MicroLight } from './MicroLight';
17+
18+
const PROGRESS_POLL_MS = 3000;
19+
const PRAISE_VISIBLE_MS = 4200;
20+
21+
export function FirstDiveGuide() {
22+
const { profile, completedSteps, justCompleted, checkProgress, skipDive, clearJustCompleted } =
23+
useFirstDiveStore();
24+
const startMiniDive = usePomodoroStore((s) => s.startMiniDive);
25+
const pomodoroRunning = usePomodoroStore((s) => s.isRunning);
26+
const navigate = useNavigate();
27+
const { pathname } = useLocation();
28+
const [showPraise, setShowPraise] = useState(false);
29+
30+
const currentStepId = getCurrentStep(profile, completedSteps);
31+
const currentStep = currentStepId ? getDiveStep(currentStepId) : null;
32+
const orderedSteps = useMemo(
33+
() => (profile ? orderStepsByProfile(profile) : DIVE_STEPS),
34+
[profile],
35+
);
36+
37+
// ── 进度轮询:仅 diving 阶段挂载 ──
38+
useEffect(() => {
39+
const timer = setInterval(() => { checkProgress().catch(() => {}); }, PROGRESS_POLL_MS);
40+
const onFocus = () => { checkProgress().catch(() => {}); };
41+
window.addEventListener('focus', onFocus);
42+
return () => { clearInterval(timer); window.removeEventListener('focus', onFocus); };
43+
}, [checkProgress]);
44+
45+
// ── praise 展示:步骤刚完成时露出几秒 ──
46+
useEffect(() => {
47+
if (!justCompleted) return;
48+
setShowPraise(true);
49+
const timer = setTimeout(() => {
50+
setShowPraise(false);
51+
clearJustCompleted();
52+
}, PRAISE_VISIBLE_MS);
53+
return () => clearTimeout(timer);
54+
}, [justCompleted, clearJustCompleted]);
55+
56+
const onPrimaryAction = () => {
57+
if (!currentStep) return;
58+
if (!pathname.startsWith(currentStep.route)) {
59+
navigate(currentStep.route);
60+
return;
61+
}
62+
// 已在深潜页且当前步骤是迷你潜水 → 直接替用户按下开始
63+
if (currentStep.id === 'pomodoro' && !pomodoroRunning) {
64+
startMiniDive();
65+
}
66+
};
67+
68+
const primaryLabel = (() => {
69+
if (!currentStep) return '';
70+
if (!pathname.startsWith(currentStep.route)) return '带我去';
71+
if (currentStep.id === 'pomodoro') return pomodoroRunning ? '潜水中…' : '开始 3 分钟迷你深潜';
72+
return '就在这页,试试吧';
73+
})();
74+
75+
const praiseText = justCompleted ? getDiveStep(justCompleted).praise : '';
76+
77+
return (
78+
<motion.div
79+
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[70] w-[min(560px,calc(100vw-2rem))]"
80+
initial={{ opacity: 0, y: 24 }}
81+
animate={{ opacity: 1, y: 0 }}
82+
exit={{ opacity: 0, y: 24 }}
83+
transition={{ duration: 0.5, ease: [0, 0, 0.2, 1] }}
84+
>
85+
<div className={cn(
86+
'rounded-2xl px-5 py-4',
87+
'bg-[#0A1826]/92 backdrop-blur-2xl border border-cyan-400/15',
88+
'shadow-[0_8px_32px_rgba(4,10,20,0.5),0_0_24px_rgba(6,182,212,0.08)]',
89+
)}>
90+
<AnimatePresence mode="wait">
91+
{showPraise ? (
92+
/* 步骤完成的回应 */
93+
<motion.div
94+
key="praise"
95+
className="flex items-center gap-3"
96+
initial={{ opacity: 0, y: 6 }}
97+
animate={{ opacity: 1, y: 0 }}
98+
exit={{ opacity: 0, y: -6 }}
99+
>
100+
<MicroLight size={16} />
101+
<p className="text-[13px] text-cyan-100/85 leading-relaxed">{praiseText}</p>
102+
</motion.div>
103+
) : currentStep ? (
104+
/* 当前步骤引导 */
105+
<motion.div
106+
key={currentStep.id}
107+
initial={{ opacity: 0, y: 6 }}
108+
animate={{ opacity: 1, y: 0 }}
109+
exit={{ opacity: 0, y: -6 }}
110+
>
111+
<div className="flex items-start gap-3">
112+
<MicroLight size={16} className="mt-1" />
113+
<p className="flex-1 text-[13px] text-white/80 leading-relaxed">
114+
{currentStep.instruction}
115+
</p>
116+
</div>
117+
<div className="mt-3 flex items-center gap-3">
118+
{/* 潜航进度点 */}
119+
<div className="flex items-center gap-1.5 mr-auto" aria-label="首潜进度">
120+
{orderedSteps.map((s) => (
121+
<span
122+
key={s.id}
123+
title={s.title}
124+
className={cn(
125+
'h-1.5 rounded-full transition-all duration-500',
126+
completedSteps.includes(s.id)
127+
? 'w-5 bg-cyan-400/80'
128+
: s.id === currentStep.id
129+
? 'w-3.5 bg-cyan-400/40 animate-pulse'
130+
: 'w-1.5 bg-white/15',
131+
)}
132+
/>
133+
))}
134+
</div>
135+
<button
136+
onClick={skipDive}
137+
className="text-[12px] text-white/30 hover:text-white/60 transition-colors px-2 py-1.5"
138+
>
139+
先跳过
140+
</button>
141+
<button
142+
onClick={onPrimaryAction}
143+
disabled={currentStep.id === 'pomodoro' && pomodoroRunning && pathname.startsWith(currentStep.route)}
144+
className={cn(
145+
'flex items-center gap-1.5 px-4 py-1.5 rounded-full text-[12px] font-medium',
146+
'bg-cyan-500/90 text-white hover:bg-cyan-400 transition-colors',
147+
'shadow-[0_2px_12px_rgba(6,182,212,0.35)]',
148+
'disabled:opacity-50 disabled:pointer-events-none',
149+
)}
150+
>
151+
{currentStep.id === 'pomodoro' && pathname.startsWith(currentStep.route)
152+
? <Play className="w-3.5 h-3.5" strokeWidth={2} />
153+
: <ArrowRight className="w-3.5 h-3.5" strokeWidth={2} />}
154+
{primaryLabel}
155+
</button>
156+
</div>
157+
</motion.div>
158+
) : null}
159+
</AnimatePresence>
160+
</div>
161+
</motion.div>
162+
);
163+
}

0 commit comments

Comments
 (0)