diff --git a/client/electron/windowManager.ts b/client/electron/windowManager.ts
index 150333e1..e91b7f08 100644
--- a/client/electron/windowManager.ts
+++ b/client/electron/windowManager.ts
@@ -163,7 +163,12 @@ export function createMainWindow(
} else if (savedChoice === 'minimize') {
win.hide();
} else {
- // 无记忆选择,通知前端弹出确认对话框
+ // 无记忆选择,通知前端弹出确认对话框。
+ // 窗口可能处于最小化/隐藏状态(如任务栏右键「关闭窗口」),
+ // 必须先恢复并聚焦,否则应用内对话框用户不可见,表现为“无法关闭”
+ if (win.isMinimized()) win.restore();
+ if (!win.isVisible()) win.show();
+ win.focus();
win.webContents.send('window:closing');
}
} else if (!syncBeforeQuitCompleted) {
diff --git a/client/src/components/layout/AppLayout.tsx b/client/src/components/layout/AppLayout.tsx
index e260895d..2dcf8f57 100644
--- a/client/src/components/layout/AppLayout.tsx
+++ b/client/src/components/layout/AppLayout.tsx
@@ -23,6 +23,7 @@ import { HelpCenter } from '@/components/onboarding/HelpCenter';
import { useOnboardingStore } from '@/components/onboarding/useOnboardingStore';
import { useRuntimeEnv } from '@/lib/env/useRuntimeEnv';
import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
+import { FirstDiveGate } from '@/features/onboarding/firstDive/FirstDiveGate';
export default function AppLayout() {
const { pathname } = useLocation();
@@ -164,6 +165,7 @@ export default function AppLayout() {
{/* 全局组件 */}
+
diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx
index 9445da3b..a9a32f0c 100644
--- a/client/src/components/layout/Sidebar.tsx
+++ b/client/src/components/layout/Sidebar.tsx
@@ -16,24 +16,26 @@ import { useAuth } from '@/lib/auth/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { SPRING, BEAT } from '@/lib/animation/springConfig';
import { soundPlayer } from '@/lib/audio/SoundPlayer';
+import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore';
import FeedbackPanel from './FeedbackPanel';
/* ── 导航配置 ── */
+// subtitle:新手期双标签副标题(隐喻名无法自解释,首潜完成前附直白说明)
const navSection1 = [
{ to: '/', label: '首页', icon: Home, shortcut: '⌘ 1', exact: true },
];
const navSection2 = [
- { to: '/pomodoro', label: '深潜', icon: Timer, shortcut: '⌘ 2', dotColor: 'bg-brand-500' },
- { to: '/notes', label: '结礁', icon: FileText, shortcut: '⌘ 3', dotColor: 'bg-note' },
- { to: '/flashcards', label: '反衰减呼吸', icon: Layers, shortcut: '⌘ 4', dotColor: 'bg-flashcard' },
- { to: '/feynman', label: '浮出水面', icon: Lightbulb, shortcut: '⌘ 5', dotColor: 'bg-feynman' },
+ { to: '/pomodoro', label: '深潜', subtitle: '专注番茄钟', icon: Timer, shortcut: '⌘ 2', dotColor: 'bg-brand-500' },
+ { to: '/notes', label: '结礁', subtitle: '学习笔记', icon: FileText, shortcut: '⌘ 3', dotColor: 'bg-note' },
+ { to: '/flashcards', label: '反衰减呼吸', subtitle: '记忆闪卡', icon: Layers, shortcut: '⌘ 4', dotColor: 'bg-flashcard' },
+ { to: '/feynman', label: '浮出水面', subtitle: '费曼讲解', icon: Lightbulb, shortcut: '⌘ 5', dotColor: 'bg-feynman' },
];
const navSection3 = [
{ to: '/analytics', label: '数据分析', icon: BarChart3, shortcut: '⌘ 6' },
- { to: '/inspiration', label: '萤火海沟', icon: Sparkles, shortcut: '⌘ 6' },
- { to: '/classroom', label: '回声定位', icon: Clapperboard, shortcut: '⌘ 7', dotColor: 'bg-classroom' },
+ { to: '/inspiration', label: '萤火海沟', subtitle: '灵感收集', icon: Sparkles, shortcut: '⌘ 6' },
+ { to: '/classroom', label: '回声定位', subtitle: '课堂采集', icon: Clapperboard, shortcut: '⌘ 7', dotColor: 'bg-classroom' },
];
/* ── 蔡格尼克效应:待继续任务提示池 ── */
@@ -73,6 +75,8 @@ export default function Sidebar() {
const setCaptureOpen = useCaptureStore((s) => s.setOpen);
const { user, isAuthenticated } = useAuth();
const { theme, toggleTheme } = useTheme();
+ // 新手期显示模块副标题(首潜完成后自动隐去)
+ const isNewbie = useIsNewbiePhase();
// TODO: 接入真实学习进度数据
const _progressItems: { subject: string; progress: number }[] = [];
@@ -194,9 +198,10 @@ export default function Sidebar() {
)}
{collapsed &&
}
- {navSection2.map(({ to, label, icon: Icon, shortcut, dotColor }, i) => (
+ {navSection2.map(({ to, label, subtitle, icon: Icon, shortcut, dotColor }, i) => (
@@ -209,9 +214,10 @@ export default function Sidebar() {
)}
{collapsed && }
- {navSection3.map(({ to, label, icon: Icon, shortcut, dotColor }, i) => (
+ {navSection3.map(({ to, label, subtitle, icon: Icon, shortcut, dotColor }, i) => (
@@ -316,6 +322,8 @@ export default function Sidebar() {
interface SidebarItemProps {
to: string;
label: string;
+ /** 新手期双标签副标题(如"专注番茄钟"),传入即显示 */
+ subtitle?: string;
icon: React.ComponentType<{ className?: string; strokeWidth?: string | number }>;
shortcut?: string;
dotColor?: string;
@@ -324,7 +332,7 @@ interface SidebarItemProps {
index?: number;
}
-function SidebarItem({ to, label, icon: Icon, shortcut, dotColor, collapsed, end, index = 0 }: SidebarItemProps) {
+function SidebarItem({ to, label, subtitle, icon: Icon, shortcut, dotColor, collapsed, end, index = 0 }: SidebarItemProps) {
const location = useLocation();
// 切换到不同模块时播放导航音效(点击当前已激活项不响)
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
isActive ? 'opacity-100' : 'opacity-60 group-hover:opacity-100',
)}>
{label}
+ {subtitle && (
+ · {subtitle}
+ )}
)}
{/* 快捷键提示 — hover 显示 */}
diff --git a/client/src/components/onboarding/OnboardingOverlay.tsx b/client/src/components/onboarding/OnboardingOverlay.tsx
index d0574934..d1e833c4 100644
--- a/client/src/components/onboarding/OnboardingOverlay.tsx
+++ b/client/src/components/onboarding/OnboardingOverlay.tsx
@@ -6,6 +6,7 @@
import { useEffect, useCallback } from 'react';
import { AnimatePresence } from 'framer-motion';
import { useOnboardingStore, isGuideDone } from './useOnboardingStore';
+import { loadFirstDiveState } from '@/features/onboarding/firstDive/firstDiveStorage';
import { Step1Welcome } from './steps/Step1Welcome';
import { Step2Navigate } from './steps/Step2Navigate';
import { Step3CameraFlight } from './steps/Step3CameraFlight';
@@ -19,7 +20,10 @@ export function OnboardingOverlay() {
useOnboardingStore();
// 初始化:检查 localStorage,若未完成则 1.5s 后自动启动
+ // 首潜(L0/L1)未结束时不自动启动,避免双引导叠加轰炸新用户
useEffect(() => {
+ const diveStage = loadFirstDiveState().stage;
+ if (diveStage === 'landing' || diveStage === 'diving') return;
if (!isGuideDone()) {
const timer = setTimeout(() => startGuide(), 1500);
return () => clearTimeout(timer);
diff --git a/client/src/components/ui/ContextMenu.tsx b/client/src/components/ui/ContextMenu.tsx
index 6202cddb..c88a4549 100644
--- a/client/src/components/ui/ContextMenu.tsx
+++ b/client/src/components/ui/ContextMenu.tsx
@@ -1,7 +1,10 @@
/**
* @ai-context: UI 基础组件(shadcn/radix 封装):ContextMenu。
+ * 定位机制:Radix ContextMenu 的菜单锚点取自触发器真实收到的 contextmenu 事件坐标,
+ * 因此挂载后向隐藏触发器派发携带 position 坐标的原生事件来打开菜单;
+ * 绝不可强制传 open(锚点未初始化会导致菜单固定在屏幕左上角)。
*/
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect, useRef } from 'react';
import * as RadixContextMenu from '@radix-ui/react-context-menu';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
@@ -120,6 +123,8 @@ export const ContextMenu = ({
onSelect,
onClose,
}: ContextMenuProps) => {
+ const triggerRef = useRef(null);
+
const handleSelect = useCallback(
(itemKey: string) => {
onSelect(itemKey, context);
@@ -128,10 +133,24 @@ export const ContextMenu = ({
[onSelect, context, onClose],
);
+ // 向隐藏触发器派发带坐标的 contextmenu 事件,让 Radix 记录锚点并打开菜单;
+ // position 变化时(菜单未关闭又右键另一目标)重新派发以重定位
+ useEffect(() => {
+ triggerRef.current?.dispatchEvent(new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ clientX: position.x,
+ clientY: position.y,
+ }));
+ }, [position.x, position.y]);
+
return (
- { if (!v) onClose(); }}>
+ { if (!v) onClose(); }}>
{/* Hidden trigger at target position */}
e.stopPropagation()}
style={{
position: 'fixed',
left: position.x,
@@ -147,7 +166,7 @@ export const ContextMenu = ({
e.preventDefault()}
className={cn(
'z-[9999] bg-bg-elevated/90 backdrop-blur-2xl border border-border/50 rounded-kb-md shadow-xl py-1 min-w-[160px]',
diff --git a/client/src/features/onboarding/firstDive/FirstDiveGate.tsx b/client/src/features/onboarding/firstDive/FirstDiveGate.tsx
new file mode 100644
index 00000000..e306e139
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/FirstDiveGate.tsx
@@ -0,0 +1,37 @@
+/**
+ * 首潜编排 Gate — 按引导阶段挂载 L0/L1(AppLayout 全局挂载一次)
+ *
+ * @ai-context: bootstrap 负责旧标记迁移、老用户判定与手册种子(幂等);
+ * stage 为 done/skipped 时本组件渲染 null,对存量用户零打扰。
+ * 最后一步的 praise 需要在 stage 变为 done 后仍短暂展示,故 diving 判断
+ * 额外放行 justCompleted 存在的瞬间。
+ */
+import { useEffect } from 'react';
+import { AnimatePresence } from 'framer-motion';
+import { LandingQuestion } from './LandingQuestion';
+import { FirstDiveGuide } from './FirstDiveGuide';
+import { useFirstDiveStore } from './useFirstDiveStore';
+
+export function FirstDiveGate() {
+ const stage = useFirstDiveStore((s) => s.stage);
+ const isReady = useFirstDiveStore((s) => s.isReady);
+ const justCompleted = useFirstDiveStore((s) => s.justCompleted);
+ const bootstrap = useFirstDiveStore((s) => s.bootstrap);
+
+ useEffect(() => {
+ bootstrap().catch(() => {});
+ }, [bootstrap]);
+
+ if (!isReady) return null;
+
+ const showLanding = stage === 'landing';
+ // done 后放行片刻,让最后一句 praise 说完
+ const showGuide = stage === 'diving' || (stage === 'done' && justCompleted !== null);
+
+ return (
+
+ {showLanding && }
+ {showGuide && }
+
+ );
+}
diff --git a/client/src/features/onboarding/firstDive/FirstDiveGuide.tsx b/client/src/features/onboarding/firstDive/FirstDiveGuide.tsx
new file mode 100644
index 00000000..f7b8c2ff
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/FirstDiveGuide.tsx
@@ -0,0 +1,163 @@
+/**
+ * 首潜引导条(L1)— 底部常驻微光伴航,带做完整学习循环
+ *
+ * @ai-context: 完成检测 = 轮询 checkProgress(数据基线差值),与各模块
+ * UI 零耦合;轮询仅在 diving 阶段挂载(3s 间隔 + 窗口聚焦触发)。
+ * 跳过用可见按钮而非 Esc——Esc 已被 AppLayout 用于退出模块,不抢占。
+ */
+import { useEffect, useMemo, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { AnimatePresence, motion } from 'framer-motion';
+import { ArrowRight, Play } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { usePomodoroStore } from '@/features/pomodoro/store/usePomodoroStore';
+import { DIVE_STEPS, getDiveStep, orderStepsByProfile } from './diveSteps';
+import { getCurrentStep, useFirstDiveStore } from './useFirstDiveStore';
+import { MicroLight } from './MicroLight';
+
+const PROGRESS_POLL_MS = 3000;
+const PRAISE_VISIBLE_MS = 4200;
+
+export function FirstDiveGuide() {
+ const { profile, completedSteps, justCompleted, checkProgress, skipDive, clearJustCompleted } =
+ useFirstDiveStore();
+ const startMiniDive = usePomodoroStore((s) => s.startMiniDive);
+ const pomodoroRunning = usePomodoroStore((s) => s.isRunning);
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const [showPraise, setShowPraise] = useState(false);
+
+ const currentStepId = getCurrentStep(profile, completedSteps);
+ const currentStep = currentStepId ? getDiveStep(currentStepId) : null;
+ const orderedSteps = useMemo(
+ () => (profile ? orderStepsByProfile(profile) : DIVE_STEPS),
+ [profile],
+ );
+
+ // ── 进度轮询:仅 diving 阶段挂载 ──
+ useEffect(() => {
+ const timer = setInterval(() => { checkProgress().catch(() => {}); }, PROGRESS_POLL_MS);
+ const onFocus = () => { checkProgress().catch(() => {}); };
+ window.addEventListener('focus', onFocus);
+ return () => { clearInterval(timer); window.removeEventListener('focus', onFocus); };
+ }, [checkProgress]);
+
+ // ── praise 展示:步骤刚完成时露出几秒 ──
+ useEffect(() => {
+ if (!justCompleted) return;
+ setShowPraise(true);
+ const timer = setTimeout(() => {
+ setShowPraise(false);
+ clearJustCompleted();
+ }, PRAISE_VISIBLE_MS);
+ return () => clearTimeout(timer);
+ }, [justCompleted, clearJustCompleted]);
+
+ const onPrimaryAction = () => {
+ if (!currentStep) return;
+ if (!pathname.startsWith(currentStep.route)) {
+ navigate(currentStep.route);
+ return;
+ }
+ // 已在深潜页且当前步骤是迷你潜水 → 直接替用户按下开始
+ if (currentStep.id === 'pomodoro' && !pomodoroRunning) {
+ startMiniDive();
+ }
+ };
+
+ const primaryLabel = (() => {
+ if (!currentStep) return '';
+ if (!pathname.startsWith(currentStep.route)) return '带我去';
+ if (currentStep.id === 'pomodoro') return pomodoroRunning ? '潜水中…' : '开始 3 分钟迷你深潜';
+ return '就在这页,试试吧';
+ })();
+
+ const praiseText = justCompleted ? getDiveStep(justCompleted).praise : '';
+
+ return (
+
+
+
+ {showPraise ? (
+ /* 步骤完成的回应 */
+
+
+ {praiseText}
+
+ ) : currentStep ? (
+ /* 当前步骤引导 */
+
+
+
+
+ {currentStep.instruction}
+
+
+
+ {/* 潜航进度点 */}
+
+ {orderedSteps.map((s) => (
+
+ ))}
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/client/src/features/onboarding/firstDive/LandingQuestion.tsx b/client/src/features/onboarding/firstDive/LandingQuestion.tsx
new file mode 100644
index 00000000..5c5d766d
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/LandingQuestion.tsx
@@ -0,0 +1,100 @@
+/**
+ * 着陆之问(L0)— 首启唯一的一个问题,替代多步产品介绍
+ *
+ * @ai-context: 全屏覆盖层(非路由页),由 FirstDiveGate 按 stage==='landing'
+ * 挂载。选择画像后进入首潜(diving)或自由探索(skipped)。
+ * 画像仅写入本地 kb-onboarding-v2,不上云。
+ */
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { motion } from 'framer-motion';
+import { cn } from '@/lib/utils';
+import type { OnboardingProfile } from './types';
+import { LANDING_OPTIONS, orderStepsByProfile } from './diveSteps';
+import { useFirstDiveStore } from './useFirstDiveStore';
+import { MicroLight } from './MicroLight';
+
+export function LandingQuestion() {
+ const answerLanding = useFirstDiveStore((s) => s.answerLanding);
+ const navigate = useNavigate();
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSelect = async (profile: OnboardingProfile) => {
+ if (submitting) return;
+ setSubmitting(true);
+ await answerLanding(profile);
+ if (profile !== 'explore') {
+ // 直接落到首潜第一步的页面,让引导条接管
+ navigate(orderStepsByProfile(profile)[0].route);
+ }
+ };
+
+ return (
+
+ {/* 微光自我介绍 */}
+
+
+
+ 我是微光,这片海的守夜人。
+
+
+
+
+ 此刻,最困扰你的是什么?
+
+
+ 选一个,我带你从那里潜下去。
+
+
+
+ {LANDING_OPTIONS.map((opt, i) => (
+ handleSelect(opt.profile)}
+ disabled={submitting}
+ className={cn(
+ 'w-full text-left px-5 py-4 rounded-2xl transition-all duration-300 group',
+ 'bg-white/[0.04] border border-white/10 backdrop-blur-sm',
+ 'hover:bg-cyan-400/10 hover:border-cyan-400/30 hover:shadow-[0_0_24px_rgba(6,182,212,0.15)]',
+ submitting && 'opacity-50 pointer-events-none',
+ )}
+ initial={{ opacity: 0, y: 16 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 1.0 + i * 0.12, duration: 0.5 }}
+ whileTap={{ scale: 0.98 }}
+ >
+
+ {opt.label}
+
+
+ {opt.hint}
+
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/features/onboarding/firstDive/MicroLight.tsx b/client/src/features/onboarding/firstDive/MicroLight.tsx
new file mode 100644
index 00000000..1b2bbdae
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/MicroLight.tsx
@@ -0,0 +1,32 @@
+/**
+ * 微光 — 水母伴航生命体的 P1 雏形(纯 CSS 光点,无外部资产)
+ *
+ * @ai-context: 视觉资产决策(遗留问题①)——P1 用赛博青 CSS 光晕光点
+ * 代替水母动画,零依赖零资产成本;P2 升级 SVG/Lottie 时仅替换此组件。
+ * 纯展示组件,无副作用。
+ */
+import { motion } from 'framer-motion';
+import { cn } from '@/lib/utils';
+
+interface MicroLightProps {
+ /** 尺寸(px),默认 14 */
+ size?: number;
+ className?: string;
+}
+
+export function MicroLight({ size = 14, className }: MicroLightProps) {
+ return (
+
+ );
+}
diff --git a/client/src/features/onboarding/firstDive/diveSteps.ts b/client/src/features/onboarding/firstDive/diveSteps.ts
new file mode 100644
index 00000000..56fec4e6
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/diveSteps.ts
@@ -0,0 +1,70 @@
+/**
+ * 首潜步骤定义 · 纯数据
+ *
+ * @ai-context: 步骤完成检测采用"数据基线差值"机制——首潜开始时记录
+ * 各表计数基线,某表计数超过基线即判定该步完成(见 useFirstDiveStore)。
+ * 文案遵循品牌人格:微光水母语气,不催促、不评判。
+ */
+import type { DiveStepDef, DiveStepId, OnboardingProfile } from './types';
+
+/** 首潜步骤(完整学习循环:专注 → 记录 → 复习 → 复述) */
+export const DIVE_STEPS: DiveStepDef[] = [
+ {
+ id: 'pomodoro',
+ route: '/pomodoro',
+ title: '完成一次迷你深潜',
+ instruction: '先试一次 3 分钟的迷你深潜吧——不用准备什么,感受一下专注沉下去的样子。',
+ praise: '你完成了第一次下潜。水面之下,比想象中安静。',
+ },
+ {
+ id: 'note',
+ route: '/notes',
+ title: '记下一条笔记',
+ instruction: '随手记点什么——刚才想到的、今天学到的,一句话就够,它会沉淀成你的第一块暗礁。',
+ praise: '第一块暗礁已经形成。碎片落进海里,就不会再丢了。',
+ },
+ {
+ id: 'review',
+ route: '/flashcards',
+ title: '呼吸一次(复习手册卡)',
+ instruction: '打开《潜航员手册》复习几张卡——它会教你怎么用这片海,而且过几天还会自己回来找你。',
+ praise: '第一次呼吸完成。这些卡片会在你快忘记时,准时浮现。',
+ },
+ {
+ id: 'feynman',
+ route: '/feynman',
+ title: '浮出水面说一句',
+ instruction: '最后一步:用一句话讲讲你刚才学到的东西。讲得出来,才是真的懂。',
+ praise: '你浮出了水面。首潜完成——这片海,现在是你的了。',
+ },
+];
+
+/** 按步骤 id 查找定义 */
+export const getDiveStep = (id: DiveStepId): DiveStepDef =>
+ DIVE_STEPS.find((s) => s.id === id) ?? DIVE_STEPS[0];
+
+/**
+ * 根据画像决定首潜起始步骤(着陆之问的分流规则):
+ * 走神 → 深潜起步;背了就忘 → 直接从复习手册开始;讲不出来 → 费曼起步。
+ * 起始步骤之前的步骤不会被跳过,只是排到循环末尾。
+ */
+export const orderStepsByProfile = (profile: OnboardingProfile): DiveStepDef[] => {
+ const startId: DiveStepId =
+ profile === 'memory' ? 'review'
+ : profile === 'expression' ? 'feynman'
+ : 'pomodoro';
+ const startIndex = DIVE_STEPS.findIndex((s) => s.id === startId);
+ return [...DIVE_STEPS.slice(startIndex), ...DIVE_STEPS.slice(0, startIndex)];
+};
+
+/** 着陆之问选项文案(L0) */
+export const LANDING_OPTIONS: Array<{
+ profile: OnboardingProfile;
+ label: string;
+ hint: string;
+}> = [
+ { profile: 'focus', label: '一学习就走神', hint: '从一次 3 分钟的迷你深潜开始' },
+ { profile: 'memory', label: '背了就忘', hint: '从一副会自己回来的卡组开始' },
+ { profile: 'expression', label: '学了却讲不出来', hint: '从把一件事讲清楚开始' },
+ { profile: 'explore', label: '我只想先随便看看', hint: '直接进入,自由探索' },
+];
diff --git a/client/src/features/onboarding/firstDive/firstDive.test.ts b/client/src/features/onboarding/firstDive/firstDive.test.ts
new file mode 100644
index 00000000..e32c6dcb
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/firstDive.test.ts
@@ -0,0 +1,165 @@
+/**
+ * 首潜引导系统单元测试
+ *
+ * @ai-context: 覆盖存储迁移、种子幂等/版本追加、步骤推进纯函数。
+ * 存储层全部 Mock,禁止连接真实 IndexedDB。
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock 存储层,阻断真实 Dexie 依赖链
+vi.mock('@/lib/storage', () => ({
+ db: {},
+ flashcardDeckStore: {},
+ flashcardStore: {},
+}));
+
+import {
+ loadFirstDiveState,
+ saveFirstDiveState,
+ createInitialState,
+ FIRST_DIVE_STORAGE_KEY,
+} from './firstDiveStorage';
+import { seedHandbookDeck } from './seedHandbook';
+import { getCurrentStep } from './useFirstDiveStore';
+import { DIVE_STEPS, orderStepsByProfile } from './diveSteps';
+import { HANDBOOK_CARDS, HANDBOOK_DECK_ID, HANDBOOK_VERSION_KEY } from './handbookDeck';
+import type { Flashcard, FlashcardDeck } from '@/types/models';
+
+beforeEach(() => {
+ localStorage.clear();
+ vi.clearAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// firstDiveStorage
+// ---------------------------------------------------------------------------
+
+describe('firstDiveStorage', () => {
+ it('should return initial landing state for fresh install', () => {
+ // Act
+ const state = loadFirstDiveState();
+ // Assert
+ expect(state.stage).toBe('landing');
+ expect(state.completedSteps).toEqual([]);
+ });
+
+ it('should migrate legacy onboarding mark to done', () => {
+ // Arrange:旧版引导完成标记存在
+ localStorage.setItem('kb-onboarding-done', 'true');
+ // Act
+ const state = loadFirstDiveState();
+ // Assert:老用户直接视为完成,且已回写新 key
+ expect(state.stage).toBe('done');
+ expect(localStorage.getItem(FIRST_DIVE_STORAGE_KEY)).toContain('"done"');
+ });
+
+ it('should fall back to defaults when persisted JSON is corrupted', () => {
+ // Arrange
+ localStorage.setItem(FIRST_DIVE_STORAGE_KEY, '{not-json');
+ // Act
+ const state = loadFirstDiveState();
+ // Assert:解析失败降级为 done,不反复弹引导
+ expect(state.stage).toBe('done');
+ });
+
+ it('should round-trip save and load', () => {
+ // Arrange
+ const saved = { ...createInitialState(), stage: 'diving' as const, completedSteps: ['pomodoro' as const] };
+ // Act
+ saveFirstDiveState(saved);
+ const loaded = loadFirstDiveState();
+ // Assert
+ expect(loaded.stage).toBe('diving');
+ expect(loaded.completedSteps).toEqual(['pomodoro']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// seedHandbookDeck
+// ---------------------------------------------------------------------------
+
+function createMockStores(existingDeck?: FlashcardDeck, existingCards: Flashcard[] = []) {
+ return {
+ deckStore: {
+ getById: vi.fn().mockResolvedValue(existingDeck),
+ create: vi.fn().mockResolvedValue(HANDBOOK_DECK_ID),
+ },
+ cardStore: {
+ where: vi.fn().mockResolvedValue(existingCards),
+ create: vi.fn().mockResolvedValue('card-id'),
+ },
+ };
+}
+
+const fakeDeck: FlashcardDeck = {
+ id: HANDBOOK_DECK_ID, name: '潜航员手册', createdAt: new Date(), updatedAt: new Date(), order: 0,
+};
+
+describe('seedHandbookDeck', () => {
+ it('should create deck and all cards on fresh install', async () => {
+ // Arrange
+ const { deckStore, cardStore } = createMockStores(undefined);
+ // Act
+ const added = await seedHandbookDeck(deckStore, cardStore);
+ // Assert
+ expect(deckStore.create).toHaveBeenCalledTimes(1);
+ expect(cardStore.create).toHaveBeenCalledTimes(HANDBOOK_CARDS.length);
+ expect(added).toBe(HANDBOOK_CARDS.length);
+ });
+
+ it('should be idempotent when deck exists and version matches', async () => {
+ // Arrange:牌组已存在且版本一致
+ localStorage.setItem(HANDBOOK_VERSION_KEY, '999');
+ const { deckStore, cardStore } = createMockStores(fakeDeck);
+ // Act
+ const added = await seedHandbookDeck(deckStore, cardStore);
+ // Assert:快路径,零写入
+ expect(deckStore.create).not.toHaveBeenCalled();
+ expect(cardStore.create).not.toHaveBeenCalled();
+ expect(added).toBe(0);
+ });
+
+ it('should only append missing cards on version upgrade (protect review progress)', async () => {
+ // Arrange:旧版本,已有前 3 张卡
+ localStorage.setItem(HANDBOOK_VERSION_KEY, '0');
+ const existing = HANDBOOK_CARDS.slice(0, 3).map((c, i) => ({
+ id: `old-${i}`, deckId: HANDBOOK_DECK_ID, front: c.front, back: c.back,
+ type: 'basic', easeFactor: 2.5, interval: 3, repetitions: 2, lapses: 0,
+ dueDate: new Date(), createdAt: new Date(), updatedAt: new Date(), order: i,
+ })) as Flashcard[];
+ const { deckStore, cardStore } = createMockStores(fakeDeck, existing);
+ // Act
+ const added = await seedHandbookDeck(deckStore, cardStore);
+ // Assert:只补缺失的卡,不重建已有卡
+ expect(added).toBe(HANDBOOK_CARDS.length - 3);
+ expect(deckStore.create).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 步骤推进纯函数
+// ---------------------------------------------------------------------------
+
+describe('dive step ordering', () => {
+ it('should start from review step for memory profile', () => {
+ // Act
+ const ordered = orderStepsByProfile('memory');
+ // Assert:背了就忘 → 从手册复习起步,且不丢步骤
+ expect(ordered[0].id).toBe('review');
+ expect(ordered).toHaveLength(DIVE_STEPS.length);
+ });
+
+ it('should return first uncompleted step in profile order', () => {
+ // Act & Assert
+ expect(getCurrentStep('focus', [])).toBe('pomodoro');
+ expect(getCurrentStep('focus', ['pomodoro'])).toBe('note');
+ expect(getCurrentStep('memory', [])).toBe('review');
+ });
+
+ it('should return null when all steps completed', () => {
+ // Arrange
+ const all = DIVE_STEPS.map((s) => s.id);
+ // Act & Assert
+ expect(getCurrentStep('focus', all)).toBeNull();
+ });
+});
diff --git a/client/src/features/onboarding/firstDive/firstDiveStorage.ts b/client/src/features/onboarding/firstDive/firstDiveStorage.ts
new file mode 100644
index 00000000..8b09a673
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/firstDiveStorage.ts
@@ -0,0 +1,68 @@
+/**
+ * 首潜状态持久化(localStorage)+ 旧引导 key 迁移
+ *
+ * @ai-context: 副作用仅限 localStorage 读写。旧 key(kb-onboarding-done /
+ * kb-3d-guide-done)只读不删——保留给尚未退役的 3D 引导(P2 统一清理)。
+ * @ai-context: 老用户判定不在此处(需查 IndexedDB),见 useFirstDiveStore.bootstrap。
+ */
+import type { FirstDiveStateV2 } from './types';
+
+export const FIRST_DIVE_STORAGE_KEY = 'kb-onboarding-v2';
+
+/** 旧版引导完成标记(任一存在即视为老用户,跳过 L0/L1) */
+const LEGACY_KEYS = ['kb-onboarding-done', 'kb-3d-guide-done'] as const;
+
+export const createInitialState = (): FirstDiveStateV2 => ({
+ version: 1,
+ stage: 'landing',
+ profile: null,
+ completedSteps: [],
+ baselines: {},
+});
+
+/** 是否存在旧版引导完成标记 */
+export function hasLegacyOnboardingMark(): boolean {
+ try {
+ return LEGACY_KEYS.some((k) => localStorage.getItem(k) === 'true');
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * 读取首潜状态。
+ * 无记录时:旧标记存在 → 直接视为 done(老用户不打扰);否则返回初始态。
+ */
+export function loadFirstDiveState(): FirstDiveStateV2 {
+ try {
+ const raw = localStorage.getItem(FIRST_DIVE_STORAGE_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw) as Partial;
+ // 结构校验:字段缺失时回退默认值,保证向后兼容
+ return {
+ version: 1,
+ stage: parsed.stage ?? 'landing',
+ profile: parsed.profile ?? null,
+ completedSteps: Array.isArray(parsed.completedSteps) ? parsed.completedSteps : [],
+ baselines: parsed.baselines ?? {},
+ };
+ }
+ if (hasLegacyOnboardingMark()) {
+ const migrated: FirstDiveStateV2 = { ...createInitialState(), stage: 'done' };
+ saveFirstDiveState(migrated);
+ return migrated;
+ }
+ return createInitialState();
+ } catch {
+ // localStorage 不可用(隐私模式等)时降级为已完成,避免反复弹引导
+ return { ...createInitialState(), stage: 'done' };
+ }
+}
+
+export function saveFirstDiveState(state: FirstDiveStateV2): void {
+ try {
+ localStorage.setItem(FIRST_DIVE_STORAGE_KEY, JSON.stringify(state));
+ } catch {
+ // 写入失败静默:引导状态丢失的代价仅是重新展示
+ }
+}
diff --git a/client/src/features/onboarding/firstDive/handbookDeck.ts b/client/src/features/onboarding/firstDive/handbookDeck.ts
new file mode 100644
index 00000000..f1a192e3
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/handbookDeck.ts
@@ -0,0 +1,62 @@
+/**
+ * 《潜航员手册》自举卡组 · 纯数据
+ *
+ * @ai-context: 手册卡组是"产品自己教自己"的载体——新用户复习这副卡
+ * 即同时学会软件用法与间隔重复方法论。卡组 ID 固定,种子写入幂等。
+ * @ai-context: 版本更新机制——HANDBOOK_VERSION 递增时,seedHandbook
+ * 只追加 front 不存在的新卡,绝不覆盖用户已有的复习进度。
+ * 修改已发布卡片的 front 文本等于新增一张卡,需慎重。
+ */
+
+/** 固定牌组 ID(跨版本稳定,种子幂等判断依据) */
+export const HANDBOOK_DECK_ID = 'builtin-handbook-deck';
+
+/** 手册内容版本(追加新卡时 +1),存于 localStorage kb-handbook-version */
+export const HANDBOOK_VERSION = 1;
+
+export const HANDBOOK_VERSION_KEY = 'kb-handbook-version';
+
+export const HANDBOOK_DECK_NAME = '潜航员手册';
+
+export const HANDBOOK_DECK_DESCRIPTION =
+ '沉船遗物 · 这副卡会教你如何使用熵减——当它隔几天再次浮现时,你也在亲历间隔重复的原理。';
+
+export interface HandbookCard {
+ front: string;
+ back: string;
+}
+
+export const HANDBOOK_CARDS: HandbookCard[] = [
+ {
+ front: '「深潜」(专注番茄钟)帮你解决什么?',
+ back: '把学习切成一段段可坚持的专注(如 25 分钟),专注时白噪音隔绝干扰,结束后强制短休——对抗"一学习就走神"。',
+ },
+ {
+ front: '为什么番茄钟的休息不是浪费时间?',
+ back: '大脑在休息时才完成记忆巩固。短休 5 分钟是专注循环的一部分,跳过休息反而让后续专注质量下降。',
+ },
+ {
+ front: '「结礁」(笔记)和普通笔记软件有什么不同?',
+ back: '结礁的笔记是活的:AI 可以把一段笔记一键"结晶"成闪卡,进入复习循环——记下来只是开始,记得住才是目的。',
+ },
+ {
+ front: '一张闪卡什么时候会再次出现?',
+ back: '由间隔重复算法决定:答得越熟,下次出现隔得越久(1 天 → 3 天 → 1 周…)。就像这张卡,它会在你快忘记时准时回来。',
+ },
+ {
+ front: '「浮出水面」(费曼学习法)为什么要求你讲出来?',
+ back: '能用自己的话讲清楚,才是真的懂。讲不下去的地方就是知识盲区——AI 会扮演听众帮你找到它。',
+ },
+ {
+ front: '复习时忘了很多,正常吗?',
+ back: '完全正常。遗忘是大脑的默认行为,间隔重复正是利用"快忘时复习"来加固记忆。没关系,暗流很正常。',
+ },
+ {
+ front: '我的学习数据存在哪里?',
+ back: '全部在你自己的电脑里(本地优先)。不联网也能用;云同步是可选项,由你决定。',
+ },
+ {
+ front: '卡住了、找不到功能怎么办?',
+ back: '按 Ctrl + / 随时打开帮助中心:快速上手、快捷键、模块详解、常见问题都在里面。',
+ },
+];
diff --git a/client/src/features/onboarding/firstDive/moduleSubtitles.ts b/client/src/features/onboarding/firstDive/moduleSubtitles.ts
new file mode 100644
index 00000000..c3ce22d5
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/moduleSubtitles.ts
@@ -0,0 +1,19 @@
+/**
+ * 模块副标题映射 — 新手期双标签的唯一数据源
+ *
+ * @ai-context: 隐喻命名(深潜/结礁…)无法自解释,是新手墙之一;
+ * 新手期(首潜未完成)各导航面在模块名旁附直白副标题。
+ * 所有导航面(3D 标签 / 移动端网格 / 侧边栏)统一从此处取值,禁止各自硬编码。
+ */
+export const MODULE_SUBTITLES: Record = {
+ pomodoro: '专注番茄钟',
+ notes: '学习笔记',
+ flashcards: '记忆闪卡',
+ feynman: '费曼讲解',
+ inspiration: '灵感收集',
+ classroom: '课堂采集',
+};
+
+/** 取模块副标题;无副标题(如首页)返回 undefined */
+export const getModuleSubtitle = (moduleId: string): string | undefined =>
+ MODULE_SUBTITLES[moduleId];
diff --git a/client/src/features/onboarding/firstDive/seedHandbook.ts b/client/src/features/onboarding/firstDive/seedHandbook.ts
new file mode 100644
index 00000000..754ac690
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/seedHandbook.ts
@@ -0,0 +1,98 @@
+/**
+ * 《潜航员手册》种子服务 — 幂等写入 + 版本追加
+ *
+ * @ai-context: 副作用——写入 flashcardDecks/flashcards 表。幂等性靠固定
+ * HANDBOOK_DECK_ID 与"front 去重追加"保证;绝不修改/删除已有卡片,
+ * 保护用户复习进度(FSRS/SM-2 调度字段)。
+ * @ai-context: 依赖注入——stores 通过参数传入,测试时传 Mock,
+ * 禁止测试直连真实 IndexedDB。
+ */
+import {
+ flashcardDeckStore as defaultDeckStore,
+ flashcardStore as defaultCardStore,
+} from '@/lib/storage';
+import { getScheduler } from '@/lib/schedulingFactory';
+import type { Flashcard, FlashcardDeck } from '@/types/models';
+import {
+ HANDBOOK_DECK_ID,
+ HANDBOOK_DECK_NAME,
+ HANDBOOK_DECK_DESCRIPTION,
+ HANDBOOK_CARDS,
+ HANDBOOK_VERSION,
+ HANDBOOK_VERSION_KEY,
+} from './handbookDeck';
+
+interface DeckStoreLike {
+ getById(id: string): Promise;
+ create(item: FlashcardDeck): Promise;
+}
+
+interface CardStoreLike {
+ where(index: string, value: string): Promise;
+ create(item: Flashcard): Promise;
+}
+
+/** 构建一张手册闪卡(调度字段用当前算法的新卡初始态) */
+function buildHandbookCard(front: string, back: string): Flashcard {
+ const now = new Date();
+ const init = getScheduler().createNew();
+ return {
+ id: crypto.randomUUID(),
+ deckId: HANDBOOK_DECK_ID,
+ front,
+ back,
+ type: 'basic',
+ easeFactor: init.easeFactor,
+ interval: init.interval,
+ repetitions: init.repetitions,
+ lapses: init.lapses,
+ dueDate: init.dueDate,
+ stability: init.stability,
+ difficulty: init.difficulty,
+ createdAt: now,
+ updatedAt: now,
+ order: Date.now(),
+ };
+}
+
+/**
+ * 幂等种子:牌组不存在则创建;卡片按 front 去重追加。
+ * 版本一致且牌组已存在时直接跳过(快路径,避免每次启动查卡片表)。
+ *
+ * @returns 本次新写入的卡片数
+ */
+export async function seedHandbookDeck(
+ deckStore: DeckStoreLike = defaultDeckStore,
+ cardStore: CardStoreLike = defaultCardStore,
+): Promise {
+ const existingDeck = await deckStore.getById(HANDBOOK_DECK_ID);
+ const storedVersion = Number(localStorage.getItem(HANDBOOK_VERSION_KEY) ?? '0');
+
+ if (existingDeck && storedVersion >= HANDBOOK_VERSION) return 0;
+
+ if (!existingDeck) {
+ const now = new Date();
+ await deckStore.create({
+ id: HANDBOOK_DECK_ID,
+ name: HANDBOOK_DECK_NAME,
+ description: HANDBOOK_DECK_DESCRIPTION,
+ color: '#06B6D4', // 赛博青 — 微光水母的颜色
+ createdAt: now,
+ updatedAt: now,
+ order: 0, // 排在最前,新用户第一眼可见
+ });
+ }
+
+ // 版本追加:只补 front 不存在的卡,保护已有复习进度
+ const existingCards = await cardStore.where('deckId', HANDBOOK_DECK_ID);
+ const existingFronts = new Set(existingCards.map((c) => c.front));
+ let added = 0;
+ for (const card of HANDBOOK_CARDS) {
+ if (existingFronts.has(card.front)) continue;
+ await cardStore.create(buildHandbookCard(card.front, card.back));
+ added += 1;
+ }
+
+ localStorage.setItem(HANDBOOK_VERSION_KEY, String(HANDBOOK_VERSION));
+ return added;
+}
diff --git a/client/src/features/onboarding/firstDive/types.ts b/client/src/features/onboarding/firstDive/types.ts
new file mode 100644
index 00000000..a57cca32
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/types.ts
@@ -0,0 +1,51 @@
+/**
+ * 「首潜」新手引导系统 · 领域类型
+ *
+ * @ai-context: kb-onboarding-v2 是统一的新手引导状态(替代散落的
+ * kb-onboarding-done / kb-3d-guide-done 双 key),结构变更需保持向后兼容
+ * (新增字段给默认值,禁止改名/删除已有字段)。
+ * @ai-context: 画像(profile)仅存本地 localStorage,不上云(隐私决策,
+ * 见 docs/Foresight/first-dive-onboarding-brainstorm.md §6)。
+ */
+
+/** 首潜整体阶段 */
+export type FirstDiveStage =
+ | 'landing' // 待回答着陆之问(L0)
+ | 'diving' // 首潜进行中(L1)
+ | 'done' // 已完成
+ | 'skipped'; // 已跳过
+
+/** 着陆之问的用户画像(学习困扰类型) */
+export type OnboardingProfile =
+ | 'focus' // 一学习就走神 → 从深潜开始
+ | 'memory' // 背了就忘 → 从闪卡开始
+ | 'expression' // 学了讲不出来 → 从费曼开始
+ | 'explore'; // 随便看看 → 跳过首潜
+
+/** 首潜步骤标识(顺序由 DIVE_STEPS 决定) */
+export type DiveStepId = 'pomodoro' | 'note' | 'review' | 'feynman';
+
+/** 持久化到 localStorage 的首潜状态(key: kb-onboarding-v2) */
+export interface FirstDiveStateV2 {
+ /** 状态结构版本,用于将来迁移 */
+ version: 1;
+ stage: FirstDiveStage;
+ profile: OnboardingProfile | null;
+ /** 已完成的首潜步骤 */
+ completedSteps: DiveStepId[];
+ /** 首潜开始时各数据表的基线计数(用于检测"新产生的第一条数据") */
+ baselines: Partial>;
+}
+
+/** 单个首潜步骤的静态定义 */
+export interface DiveStepDef {
+ id: DiveStepId;
+ /** 需要引导用户前往的路由 */
+ route: string;
+ /** 步骤短标题(潜航日志用) */
+ title: string;
+ /** 微光的引导语(品牌人格:不催促、不评判) */
+ instruction: string;
+ /** 完成时微光的回应 */
+ praise: string;
+}
diff --git a/client/src/features/onboarding/firstDive/useFirstDiveStore.ts b/client/src/features/onboarding/firstDive/useFirstDiveStore.ts
new file mode 100644
index 00000000..83e6a0fa
--- /dev/null
+++ b/client/src/features/onboarding/firstDive/useFirstDiveStore.ts
@@ -0,0 +1,169 @@
+/**
+ * 首潜状态机 Store(Zustand)
+ *
+ * @ai-context: 步骤完成检测 = 数据基线差值——首潜开始时记录四张表的
+ * 计数基线,轮询发现某表计数超过基线即判定对应步骤完成。
+ * 好处:只认"真实产生的数据",与各模块 UI 零耦合。
+ * @ai-context: 副作用——localStorage 持久化(firstDiveStorage)、
+ * IndexedDB 计数(fetchStepCounts)、手册种子(seedHandbookDeck)。
+ * 判定老用户(任一核心表非空)时跳过 L0/L1 且不种手册,不打扰存量用户。
+ */
+import { create } from 'zustand';
+import { db } from '@/lib/storage';
+import type { DiveStepId, FirstDiveStage, FirstDiveStateV2, OnboardingProfile } from './types';
+import { DIVE_STEPS, orderStepsByProfile } from './diveSteps';
+import { loadFirstDiveState, saveFirstDiveState } from './firstDiveStorage';
+import { seedHandbookDeck } from './seedHandbook';
+
+/** 各首潜步骤对应的数据表计数(基线差值检测的数据源) */
+export type StepCounts = Record;
+
+/** 读取四张表的当前计数(依赖注入 database 便于测试 Mock) */
+export async function fetchStepCounts(database: typeof db = db): Promise {
+ const [pomodoro, note, review, feynman] = await Promise.all([
+ database.pomodoroSessions.count(),
+ database.notes.count(),
+ database.flashcardReviews.count(),
+ database.feynmanNotes.count(),
+ ]);
+ return { pomodoro, note, review, feynman };
+}
+
+interface FirstDiveStoreState {
+ stage: FirstDiveStage;
+ profile: OnboardingProfile | null;
+ completedSteps: DiveStepId[];
+ baselines: Partial>;
+ /** bootstrap 是否已执行(防止重复初始化) */
+ isReady: boolean;
+ /** 刚完成的步骤(供 UI 展示 praise 文案,展示后清空) */
+ justCompleted: DiveStepId | null;
+
+ /** 启动引导:迁移旧标记、判定老用户、种手册 */
+ bootstrap: () => Promise;
+ /** 回答着陆之问(L0) */
+ answerLanding: (profile: OnboardingProfile) => Promise;
+ /** 轮询检测步骤完成(L1) */
+ checkProgress: () => Promise;
+ /** 跳过首潜 */
+ skipDive: () => void;
+ /** 清除 praise 展示标记 */
+ clearJustCompleted: () => void;
+}
+
+/** 当前应执行的步骤(按画像排序后第一个未完成项) */
+export function getCurrentStep(
+ profile: OnboardingProfile | null,
+ completedSteps: DiveStepId[],
+): DiveStepId | null {
+ const ordered = profile ? orderStepsByProfile(profile) : DIVE_STEPS;
+ const next = ordered.find((s) => !completedSteps.includes(s.id));
+ return next?.id ?? null;
+}
+
+const persist = (state: Pick) =>
+ saveFirstDiveState({ version: 1, ...state });
+
+export const useFirstDiveStore = create((set, get) => ({
+ stage: 'done', // bootstrap 前默认不打扰,避免闪烁
+ profile: null,
+ completedSteps: [],
+ baselines: {},
+ isReady: false,
+ justCompleted: null,
+
+ bootstrap: async () => {
+ if (get().isReady) return;
+ const persisted = loadFirstDiveState();
+
+ if (persisted.stage === 'landing') {
+ // 老用户判定:任一核心表已有数据 → 视为已完成,不种手册、不弹引导
+ try {
+ const counts = await fetchStepCounts();
+ const hasData = counts.pomodoro > 0 || counts.note > 0 || counts.feynman > 0
+ || (await db.flashcardDecks.count()) > 0;
+ if (hasData) {
+ const done: FirstDiveStateV2 = { ...persisted, stage: 'done' };
+ persist(done);
+ set({ ...done, isReady: true });
+ return;
+ }
+ // 全新用户:种入《潜航员手册》(幂等)
+ await seedHandbookDeck();
+ } catch {
+ // 数据层异常时不阻塞应用启动,按已完成处理
+ set({ stage: 'done', isReady: true });
+ return;
+ }
+ }
+
+ set({
+ stage: persisted.stage,
+ profile: persisted.profile,
+ completedSteps: persisted.completedSteps,
+ baselines: persisted.baselines,
+ isReady: true,
+ });
+ },
+
+ answerLanding: async (profile) => {
+ if (profile === 'explore') {
+ const next = { stage: 'skipped' as const, profile, completedSteps: [], baselines: {} };
+ persist(next);
+ set(next);
+ return;
+ }
+ // 记录基线:首潜只认"此后新产生"的数据
+ let baselines: Partial> = {};
+ try {
+ baselines = await fetchStepCounts();
+ } catch {
+ // 计数失败时基线为空(0),首潜仍可进行
+ }
+ const next = { stage: 'diving' as const, profile, completedSteps: [] as DiveStepId[], baselines };
+ persist(next);
+ set(next);
+ },
+
+ checkProgress: async () => {
+ const { stage, profile, completedSteps, baselines } = get();
+ if (stage !== 'diving') return;
+
+ let counts: StepCounts;
+ try {
+ counts = await fetchStepCounts();
+ } catch {
+ return; // 单次轮询失败静默,下次再试
+ }
+
+ const current = getCurrentStep(profile, completedSteps);
+ if (!current) return;
+
+ // 仅推进"当前步骤":保证引导节奏线性,避免用户乱序操作导致跳步混乱
+ if (counts[current] > (baselines[current] ?? 0)) {
+ const nextCompleted = [...completedSteps, current];
+ const allDone = nextCompleted.length >= DIVE_STEPS.length;
+ const next = {
+ stage: (allDone ? 'done' : 'diving') as FirstDiveStage,
+ profile,
+ completedSteps: nextCompleted,
+ baselines,
+ };
+ persist(next);
+ set({ ...next, justCompleted: current });
+ }
+ },
+
+ skipDive: () => {
+ const { profile, completedSteps, baselines } = get();
+ const next = { stage: 'skipped' as const, profile, completedSteps, baselines };
+ persist(next);
+ set(next);
+ },
+
+ clearJustCompleted: () => set({ justCompleted: null }),
+}));
+
+/** 新手期判定:双标签副标题等新手辅助 UI 的显隐依据 */
+export const useIsNewbiePhase = (): boolean =>
+ useFirstDiveStore((s) => s.isReady && s.stage !== 'done');
diff --git a/client/src/features/pomodoro/hooks/usePomodoroEffects.ts b/client/src/features/pomodoro/hooks/usePomodoroEffects.ts
index 2bf8296b..49895558 100644
--- a/client/src/features/pomodoro/hooks/usePomodoroEffects.ts
+++ b/client/src/features/pomodoro/hooks/usePomodoroEffects.ts
@@ -1,10 +1,13 @@
/**
* 番茄钟副作用 hook
- * @ai-context 监听 usePomodoroStore 的 lastAction 信号,触发音效、通知、成就检测、会话记录
+ * @ai-context 监听 usePomodoroStore 的 lastAction 信号,触发视觉反馈(水墨涟漪)与通知权限请求
*
* 与 usePomodoroStore 配合使用:
- * - Store 仅负责纯状态变更(计时器 tick、阶段切换、设置更新)
- * - 本 hook 负责所有副作用(音效播放、浏览器通知、成就检测、会话持久化)
+ * - Store 负责状态变更及音效播放、会话持久化、浏览器通知(tick/skip/start 内联执行)
+ * - 本 hook 仅负责 store 无法执行的 DOM 级副作用(涟漪动画、权限请求)
+ *
+ * 注意:不要在此重复播放音效或记录会话——store 已执行过,
+ * 重复执行会导致会话统计翻倍(BUG:番茄会话重复记录)。
*
* 使用方式:在番茄钟页面顶层组件中调用一次即可
* usePomodoroEffects()
@@ -12,10 +15,7 @@
import { useEffect, useRef } from 'react';
import { usePomodoroActionSignal } from '../store/usePomodoroStore';
-import { recordSession, playCompletionSound, sendNotification } from '../store/usePomodoroPersistence';
-import { soundPlayer } from '@/lib/audio/SoundPlayer';
import { triggerInkRipple } from '@/lib/animation/InkRipple';
-import type { PomodoroAction } from '../store/usePomodoroStore';
/**
* 番茄钟副作用 hook
@@ -44,125 +44,10 @@ export function usePomodoroEffects(): void {
if (signal.lastActionCounter === prevCounterRef.current) return;
prevCounterRef.current = signal.lastActionCounter;
- const action: PomodoroAction | null = signal.lastAction;
- if (!action) return;
-
- switch (action) {
- case 'start':
- soundPlayer.play('pomodoro_start');
- break;
-
- case 'pause':
- soundPlayer.play('pomodoro_pause');
- break;
-
- case 'exit_immersive':
- soundPlayer.play('pomodoro_pause');
- break;
-
- case 'tick_5min_warning':
- // store 已确保 mode !== 'class' 时才发出此信号
- soundPlayer.play('pomodoro_5min_warning');
- break;
-
- case 'tick_final':
- // store 已确保 mode !== 'class' 时才发出此信号
- soundPlayer.play('pomodoro_tick_final');
- break;
-
- case 'phase_complete':
- handlePhaseComplete(signal);
- break;
+ // 音效/会话记录/浏览器通知均由 store 内联执行,此处仅处理视觉反馈
+ if (signal.lastAction === 'phase_complete') {
+ // 水墨涟漪反馈:每次阶段完成时触发
+ triggerInkRipple(window.innerWidth / 2, window.innerHeight / 2);
}
}, [signal.lastActionCounter, signal.lastAction, signal]);
}
-
-// ─────────────────────────────────────────────────────────────
-// 内部:phase_complete 副作用处理
-// ─────────────────────────────────────────────────────────────
-
-interface PhaseCompletePayload {
- lastCompletedPhase: 'work' | 'short_break' | 'long_break' | null;
- isCycleComplete: boolean;
- lastSessionActualDuration: number | null;
- mode: 'class' | 'self_study';
- settings: {
- soundEnabled: boolean;
- notificationEnabled: boolean;
- workDuration: number;
- classDuration: number;
- };
- currentGoal: string | null;
-}
-
-function handlePhaseComplete(payload: PhaseCompletePayload): void {
- const {
- lastCompletedPhase,
- isCycleComplete,
- lastSessionActualDuration,
- mode,
- settings,
- currentGoal,
- } = payload;
-
- if (!lastCompletedPhase) return;
-
- // ── 水墨涟漪反馈:每次阶段完成时触发 ────────────────
- triggerInkRipple(window.innerWidth / 2, window.innerHeight / 2);
-
- // ── 记录会话(仅 work 阶段) ─────────────────────────────
- if (lastCompletedPhase === 'work') {
- const workMinutes = mode === 'class' ? settings.classDuration : settings.workDuration;
- const actualDuration = lastSessionActualDuration ?? workMinutes * 60;
-
- recordSession({
- mode,
- duration: workMinutes * 60,
- actualDuration,
- completedAt: new Date(),
- interrupted: false,
- goal: currentGoal ?? undefined,
- })
- .then(() => {
- // 触发成就检查(动态 import 避免循环依赖)
- import('@/lib/achievements/evaluator')
- .then(({ checkAchievements }) => {
- checkAchievements({ type: 'pomodoro_completed' })
- .then((unlocked) => {
- unlocked.forEach((a) => {
- window.dispatchEvent(
- new CustomEvent('achievement-unlocked', { detail: a }),
- );
- });
- })
- .catch(() => {});
- })
- .catch(() => {});
- })
- .catch(() => {});
- }
-
- // ── 播放音效(上课模式静默) ──────────────────────────────
- if (mode !== 'class') {
- if (settings.soundEnabled) {
- playCompletionSound();
- }
- if (lastCompletedPhase === 'work') {
- soundPlayer.play('pomodoro_work_complete');
- } else {
- soundPlayer.play('pomodoro_break_end');
- if (isCycleComplete) {
- soundPlayer.play('pomodoro_complete');
- }
- }
- }
-
- // ── 发送浏览器通知 ────────────────────────────────────────
- if (settings.notificationEnabled) {
- if (lastCompletedPhase === 'work') {
- sendNotification('又添了一段暖意', '继续深潜吧 ☕').catch(() => {});
- } else {
- sendNotification('休息结束!', '开始下一个番茄 🍅').catch(() => {});
- }
- }
-}
diff --git a/client/src/features/pomodoro/store/usePomodoroStore.test.ts b/client/src/features/pomodoro/store/usePomodoroStore.test.ts
index b9d31165..8fa59832 100644
--- a/client/src/features/pomodoro/store/usePomodoroStore.test.ts
+++ b/client/src/features/pomodoro/store/usePomodoroStore.test.ts
@@ -465,5 +465,54 @@ describe('Pomodoro Store', () => {
usePomodoroStore.getState().setMode('class');
expect(usePomodoroStore.getState().mode).toBe('class');
});
+
+ it('should reset completedCount when switching mode', () => {
+ // 上课模式累计了 7 个番茄后切到自习模式,计数应归零
+ // (回归:旧计数带入新模式导致首轮长休要等到 8 个番茄)
+ usePomodoroStore.setState({ mode: 'class', completedCount: 7 });
+ usePomodoroStore.getState().setMode('self_study');
+ expect(usePomodoroStore.getState().completedCount).toBe(0);
+ });
+
+ it('should NOT reset completedCount when setting the same mode', () => {
+ usePomodoroStore.setState({ mode: 'self_study', completedCount: 2 });
+ usePomodoroStore.getState().setMode('self_study');
+ expect(usePomodoroStore.getState().completedCount).toBe(2);
+ });
+ });
+
+ // ── class mode completedCount ────────────────────────────
+
+ describe('class mode completedCount', () => {
+ const completeWorkPhase = () => {
+ usePomodoroStore.setState({ phase: 'work', remainingSeconds: 1, isRunning: true });
+ usePomodoroStore.getState().tick(); // work → short_break
+ usePomodoroStore.setState({ remainingSeconds: 1, isRunning: true });
+ usePomodoroStore.getState().tick(); // short_break → work
+ };
+
+ it('should never enter long_break in class mode', () => {
+ usePomodoroStore.setState({
+ mode: 'class', phase: 'work', completedCount: 3,
+ isRunning: true, remainingSeconds: 1,
+ });
+ usePomodoroStore.getState().tick();
+ expect(usePomodoroStore.getState().phase).toBe('short_break');
+ });
+
+ it('should wrap completedCount within longBreakInterval in class mode (no unbounded growth)', () => {
+ // 回归:上课模式无长休导致计数永不归零、一直累加(实测 9/4)
+ usePomodoroStore.setState({ mode: 'class', completedCount: 0 });
+ for (let i = 0; i < 9; i++) completeWorkPhase();
+ const count = usePomodoroStore.getState().completedCount;
+ expect(count).toBeGreaterThanOrEqual(1);
+ expect(count).toBeLessThanOrEqual(DEFAULT_SETTINGS.longBreakInterval);
+ });
+
+ it('should wrap count via skip in class mode as well', () => {
+ usePomodoroStore.setState({ mode: 'class', phase: 'work', completedCount: 4 });
+ usePomodoroStore.getState().skip();
+ expect(usePomodoroStore.getState().completedCount).toBe(1);
+ });
});
});
diff --git a/client/src/features/pomodoro/store/usePomodoroStore.ts b/client/src/features/pomodoro/store/usePomodoroStore.ts
index 67f1e966..d299383f 100644
--- a/client/src/features/pomodoro/store/usePomodoroStore.ts
+++ b/client/src/features/pomodoro/store/usePomodoroStore.ts
@@ -44,6 +44,8 @@ interface PomodoroState {
sessionStartTime: number | null;
/** 当前番茄目标文字 */
currentGoal: string | null;
+ /** 首潜迷你会话标记:3 分钟体验潜水,会话时长按实际记录而非 settings 时长 */
+ isMiniDive: boolean;
/** 是否处于沉浸专注模式 */
isImmersive: boolean;
/** 退出沉浸后标记,用于 resume 时自动重入 */
@@ -64,6 +66,8 @@ interface PomodoroState {
lastSessionActualDuration: number | null;
start: () => void;
+ /** 开始首潜 3 分钟迷你体验(新手引导专用,不改动用户设置) */
+ startMiniDive: () => void;
pause: () => void;
resume: () => void;
reset: () => void;
@@ -90,6 +94,9 @@ const getPhaseDuration = (phase: Phase, settings: PomodoroSettings, mode?: Mode)
}
};
+/** 首潜迷你体验时长(3 分钟),见新手引导系统 */
+export const MINI_DIVE_SECONDS = 180;
+
const getNextPhase = (
currentPhase: Phase,
completedCount: number,
@@ -106,6 +113,24 @@ const getNextPhase = (
return 'work';
};
+/**
+ * 计算阶段结束后的完成计数:
+ * - 长休结束 → 归零(一轮完成)
+ * - 工作结束 → +1;上课模式无长休,计数达到周期上限后回绕,避免无限累加
+ * - 其他阶段 → 不变
+ */
+const getNextCount = (
+ phase: Phase,
+ completedCount: number,
+ longBreakInterval: number,
+ mode?: Mode,
+): number => {
+ if (phase === 'long_break') return 0;
+ if (phase !== 'work') return completedCount;
+ if (mode === 'class') return (completedCount % longBreakInterval) + 1;
+ return completedCount + 1;
+};
+
export const usePomodoroStore = create((set, get) => {
const defaultSettings: PomodoroSettings = {
workDuration: 25,
@@ -130,6 +155,7 @@ export const usePomodoroStore = create((set, get) => {
settings: defaultSettings,
sessionStartTime: null,
currentGoal: null,
+ isMiniDive: false,
isImmersive: false,
wasImmersive: false,
aiRecommendedDuration: undefined,
@@ -168,6 +194,18 @@ export const usePomodoroStore = create((set, get) => {
soundPlayer.play('pomodoro_start');
},
+ startMiniDive: () => {
+ // 3 分钟真实专注:走完整 tick 链路(记会话/触发成就),duration 按 180s 如实记录
+ set((s) => ({
+ mode: 'self_study', phase: 'work',
+ remainingSeconds: MINI_DIVE_SECONDS, totalSeconds: MINI_DIVE_SECONDS,
+ isMiniDive: true, isRunning: true, isPaused: false,
+ sessionStartTime: Date.now(), currentGoal: '首潜 · 3 分钟体验',
+ lastAction: 'start' as PomodoroAction, lastActionCounter: s.lastActionCounter + 1,
+ }));
+ soundPlayer.play('pomodoro_start');
+ },
+
pause: () => {
set((s) => ({
isRunning: false, isPaused: true,
@@ -199,12 +237,13 @@ export const usePomodoroStore = create((set, get) => {
isPaused: false,
sessionStartTime: null,
wasImmersive: false,
+ isMiniDive: false,
});
},
skip: () => {
const { phase, completedCount, settings, mode } = get();
- const newCount = phase === 'long_break' ? 0 : (phase === 'work' ? completedCount + 1 : completedCount);
+ const newCount = getNextCount(phase, completedCount, settings.longBreakInterval, mode);
const nextPhase = getNextPhase(phase, completedCount, settings.longBreakInterval, mode);
const duration = getPhaseDuration(nextPhase, settings, mode);
set({
@@ -214,12 +253,15 @@ export const usePomodoroStore = create((set, get) => {
completedCount: newCount,
isRunning: false,
isPaused: false,
+ isMiniDive: false,
});
},
setMode: (mode) => {
- const { settings, phase, isRunning, isPaused } = get();
- set({ mode });
+ const { settings, phase, isRunning, isPaused, mode: prevMode } = get();
+ if (mode === prevMode) return;
+ // 切换模式 = 开启新周期:计数归零,避免跨模式累计(自习首轮跳到 8 的根因)
+ set({ mode, completedCount: 0 });
// 切换模式后,若计时器未运行,重置当前阶段时长
if (!isRunning && !isPaused) {
const duration = getPhaseDuration(phase, settings, mode);
@@ -252,7 +294,7 @@ export const usePomodoroStore = create((set, get) => {
if (remainingSeconds <= 1) {
// Phase completed
const wasRunning = isRunning;
- const newCount = phase === 'long_break' ? 0 : (phase === 'work' ? completedCount + 1 : completedCount);
+ const newCount = getNextCount(phase, completedCount, settings.longBreakInterval, mode);
const nextPhase = getNextPhase(phase, completedCount, settings.longBreakInterval, mode);
const duration = getPhaseDuration(nextPhase, settings, mode);
const isCycleComplete = phase === 'long_break';
@@ -272,14 +314,17 @@ export const usePomodoroStore = create((set, get) => {
// 记录完成的番茄会话
let actualDuration: number | null = null;
if (phase === 'work') {
- const { sessionStartTime: sst } = get();
- const workMinutes = mode === 'class' ? settings.classDuration : settings.workDuration;
+ const { sessionStartTime: sst, isMiniDive } = get();
+ // 迷你潜水按实际 180s 记录,避免污染效率统计(首潜决策:计入成就)
+ const plannedSeconds = isMiniDive
+ ? MINI_DIVE_SECONDS
+ : (mode === 'class' ? settings.classDuration : settings.workDuration) * 60;
actualDuration = sst
? Math.round((Date.now() - sst) / 1000)
- : workMinutes * 60;
+ : plannedSeconds;
recordSession({
mode: get().mode,
- duration: workMinutes * 60,
+ duration: plannedSeconds,
actualDuration,
completedAt: new Date(),
interrupted: false,
@@ -328,6 +373,8 @@ export const usePomodoroStore = create((set, get) => {
completedCount: newCount,
isRunning: shouldAutoStart,
isPaused: !shouldAutoStart,
+ // 迷你潜水仅限一个工作阶段,阶段切换即恢复常规节律
+ isMiniDive: false,
// 切换到新阶段时清空计时,下一个 start/resume 会重新设置
sessionStartTime: null,
// 发出 phase_complete 动作信号
diff --git a/client/src/hooks/useSessionExpiry.test.ts b/client/src/hooks/useSessionExpiry.test.ts
new file mode 100644
index 00000000..561d35fb
--- /dev/null
+++ b/client/src/hooks/useSessionExpiry.test.ts
@@ -0,0 +1,59 @@
+/**
+ * @ai-context: useSessionExpiry 回归测试——并发 401 与 SIGNED_OUT 会在短时间
+ * 内派发多个 kb:session-expired 事件,冷却窗口内必须只弹一次 Toast。
+ * @ai-context: Regression test — bursts of kb:session-expired events must be
+ * deduplicated within the cooldown window (single toast, single redirect).
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { useSessionExpiry } from './useSessionExpiry';
+
+const toastSpy = vi.fn();
+
+vi.mock('@/components/ui', () => ({
+ useToast: () => ({ toast: toastSpy }),
+}));
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return React.createElement(MemoryRouter, null, children);
+}
+
+describe('useSessionExpiry - 事件风暴去重', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ toastSpy.mockClear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('冷却窗口内的多个 session-expired 事件只弹一次 Toast', () => {
+ renderHook(() => useSessionExpiry(), { wrapper });
+
+ act(() => {
+ // 模拟多个并发 401 请求 + SIGNED_OUT 各自派发事件
+ window.dispatchEvent(new CustomEvent('kb:session-expired'));
+ window.dispatchEvent(new CustomEvent('kb:session-expired'));
+ window.dispatchEvent(new CustomEvent('kb:session-expired'));
+ });
+
+ expect(toastSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('冷却窗口过后允许再次提示', () => {
+ renderHook(() => useSessionExpiry(), { wrapper });
+
+ act(() => {
+ window.dispatchEvent(new CustomEvent('kb:session-expired'));
+ });
+ act(() => {
+ vi.advanceTimersByTime(10000); // 越过冷却窗口
+ window.dispatchEvent(new CustomEvent('kb:session-expired'));
+ });
+
+ expect(toastSpy).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/client/src/hooks/useSessionExpiry.ts b/client/src/hooks/useSessionExpiry.ts
index c6b79a00..95bb1ac6 100644
--- a/client/src/hooks/useSessionExpiry.ts
+++ b/client/src/hooks/useSessionExpiry.ts
@@ -7,6 +7,13 @@ import { useToast } from '@/components/ui';
const SESSION_EXPIRED_EVENT = 'kb:session-expired';
+/**
+ * 事件去重冷却窗口:并发 401 请求与 Supabase SIGNED_OUT 会在短时间内
+ * 各自派发 session-expired,窗口内只处理第一个,避免重复弹 Toast
+ * 让用户感知为"持续要求登录"(内测反馈 bug)
+ */
+const SESSION_EXPIRED_COOLDOWN_MS = 5000;
+
/**
* 监听 session 过期事件,弹出 Toast 提示并提供重新登录入口
* 需在 AppLayout 或其他全局组件中调用
@@ -23,9 +30,16 @@ export function useSessionExpiry() {
// Bug #15: 防止重复设置 setTimeout
const timeoutRef = useRef | null>(null);
+ // 冷却窗口去重:记录上次处理事件的时间戳
+ const lastHandledAtRef = useRef(0);
useEffect(() => {
function handleSessionExpired() {
+ // 冷却窗口内的重复事件(并发 401 / SIGNED_OUT 风暴)直接忽略
+ const now = Date.now();
+ if (now - lastHandledAtRef.current < SESSION_EXPIRED_COOLDOWN_MS) return;
+ lastHandledAtRef.current = now;
+
toastRef.current({
type: 'warning',
message: '登录已过期,请重新登录',
diff --git a/client/src/lib/3d/navigation/OrbitalStore.ts b/client/src/lib/3d/navigation/OrbitalStore.ts
index af82c5b7..e634b48e 100644
--- a/client/src/lib/3d/navigation/OrbitalStore.ts
+++ b/client/src/lib/3d/navigation/OrbitalStore.ts
@@ -32,7 +32,7 @@ export const MODULE_POSITIONS: ModulePosition[] = [
{ id: 'pomodoro', position: [4, 2, -2], route: '/pomodoro', label: '深潜' },
{ id: 'notes', position: [-4, 1, -1], route: '/notes', label: '结礁' },
{ id: 'flashcards', position: [3, -2, -3], route: '/flashcards', label: '闪卡' },
- { id: 'feynman', position: [-3, -1, -4], route: '/feynman', label: '反衰减呼吸' },
+ { id: 'feynman', position: [-3, -1, -4], route: '/feynman', label: '浮出水面' },
{ id: 'inspiration', position: [0, 3, -5], route: '/inspiration', label: '萤火海沟' },
{ id: 'classroom', position: [-2, -3, -2], route: '/classroom', label: '回声定位' },
];
diff --git a/client/src/lib/3d/objects/AuroraModuleEntity.tsx b/client/src/lib/3d/objects/AuroraModuleEntity.tsx
index 4afab0c2..6b0fe557 100644
--- a/client/src/lib/3d/objects/AuroraModuleEntity.tsx
+++ b/client/src/lib/3d/objects/AuroraModuleEntity.tsx
@@ -9,6 +9,8 @@ import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { Float, Html } from '@react-three/drei';
import type { ModuleId } from '../navigation/OrbitalStore';
+import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore';
+import { getModuleSubtitle } from '@/features/onboarding/firstDive/moduleSubtitles';
export interface AuroraModuleEntityProps {
id: ModuleId;
@@ -33,7 +35,7 @@ const PLANET_CONFIGS: Record = {
pomodoro: { radius: 0.7, color: '#F97316', emissive: '#EA580C', label: '深潜' },
notes: { radius: 0.7, color: '#60A5FA', emissive: '#3B82F6', label: '结礁' },
flashcards: { radius: 0.5, color: '#34D399', emissive: '#059669', label: '闪卡' },
- feynman: { radius: 0.6, color: '#A78BFA', emissive: '#7C3AED', label: '反衰减呼吸' },
+ feynman: { radius: 0.6, color: '#A78BFA', emissive: '#7C3AED', label: '浮出水面' },
inspiration: { radius: 0.4, color: '#F472B6', emissive: '#EC4899', label: '萤火海沟' },
classroom: { radius: 0.55, color: '#14B8A6', emissive: '#0D9488', label: '回声定位' },
};
@@ -53,6 +55,9 @@ export function AuroraModuleEntity({
const ringRef = useRef(null);
const [hovered, setHovered] = useState(false);
const angleRef = useRef(initialAngle);
+ // 新手期双标签(首潜完成后自动隐去)
+ const isNewbie = useIsNewbiePhase();
+ const subtitle = getModuleSubtitle(id);
const config = PLANET_CONFIGS[id];
@@ -168,6 +173,10 @@ export function AuroraModuleEntity({
>
{config.label}
+ {/* 新手期双标签:隐喻名旁附直白副标题 */}
+ {isNewbie && subtitle && (
+ · {subtitle}
+ )}