Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion client/electron/windowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -164,6 +165,7 @@ export default function AppLayout() {
<BottomNav />

{/* 全局组件 */}
<FirstDiveGate />
<OnboardingOverlay />
<ModuleTourToast moduleId={currentModule} />
<HelpCenter />
Expand Down
29 changes: 20 additions & 9 deletions client/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];

/* ── 蔡格尼克效应:待继续任务提示池 ── */
Expand Down Expand Up @@ -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 }[] = [];
Expand Down Expand Up @@ -194,9 +198,10 @@ export default function Sidebar() {
</motion.div>
)}
{collapsed && <div className="my-1 mx-1.5 border-t border-border/20" />}
{navSection2.map(({ to, label, icon: Icon, shortcut, dotColor }, i) => (
{navSection2.map(({ to, label, subtitle, icon: Icon, shortcut, dotColor }, i) => (
<SidebarItem
key={to} to={to} label={label} icon={Icon}
subtitle={isNewbie ? subtitle : undefined}
shortcut={collapsed ? undefined : shortcut}
dotColor={dotColor} collapsed={collapsed} index={i + 1}
/>
Expand All @@ -209,9 +214,10 @@ export default function Sidebar() {
</div>
)}
{collapsed && <div className="my-1 mx-1.5 border-t border-border/20" />}
{navSection3.map(({ to, label, icon: Icon, shortcut, dotColor }, i) => (
{navSection3.map(({ to, label, subtitle, icon: Icon, shortcut, dotColor }, i) => (
<SidebarItem
key={to} to={to} label={label} icon={Icon}
subtitle={isNewbie ? subtitle : undefined}
shortcut={collapsed ? undefined : shortcut}
dotColor={dotColor} collapsed={collapsed} index={i + 6}
/>
Expand Down Expand Up @@ -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;
Expand All @@ -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 + '/');
Expand Down Expand Up @@ -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 && (
<span className="ml-1.5 text-[11px] text-text-tertiary/70">· {subtitle}</span>
)}
</span>
)}
{/* 快捷键提示 — hover 显示 */}
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/onboarding/OnboardingOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
25 changes: 22 additions & 3 deletions client/src/components/ui/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -120,6 +123,8 @@ export const ContextMenu = <C,>({
onSelect,
onClose,
}: ContextMenuProps<C>) => {
const triggerRef = useRef<HTMLSpanElement>(null);

const handleSelect = useCallback(
(itemKey: string) => {
onSelect(itemKey, context);
Expand All @@ -128,10 +133,24 @@ export const ContextMenu = <C,>({
[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 (
<RadixContextMenu.Root open onOpenChange={(v) => { if (!v) onClose(); }}>
<RadixContextMenu.Root onOpenChange={(v) => { if (!v) onClose(); }}>
{/* Hidden trigger at target position */}
<RadixContextMenu.Trigger
ref={triggerRef}
// 阻止合成事件冒泡到页面的 onContextMenu,避免重复触发 handleContextMenu
onContextMenu={(e) => e.stopPropagation()}
style={{
position: 'fixed',
left: position.x,
Expand All @@ -147,7 +166,7 @@ export const ContextMenu = <C,>({

<RadixContextMenu.Portal>
<RadixContextMenu.Content
avoidCollisions={false}
collisionPadding={8}
onCloseAutoFocus={(e: Event) => 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]',
Expand Down
37 changes: 37 additions & 0 deletions client/src/features/onboarding/firstDive/FirstDiveGate.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AnimatePresence>
{showLanding && <LandingQuestion key="landing" />}
{showGuide && <FirstDiveGuide key="guide" />}
</AnimatePresence>
);
}
Loading
Loading