Skip to content

Commit e505f1f

Browse files
authored
Merge branch 'dev': 内测反馈集中修复与新手引导
fix: 内测反馈集中修复(课堂助手/认证/窗口/番茄钟/UI)+ 新手引导
2 parents 7f0dad4 + b46be5d commit e505f1f

56 files changed

Lines changed: 4200 additions & 275 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎client/electron/windowManager.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,12 @@ export function createMainWindow(
163163
} else if (savedChoice === 'minimize') {
164164
win.hide();
165165
} else {
166-
// 无记忆选择,通知前端弹出确认对话框
166+
// 无记忆选择,通知前端弹出确认对话框。
167+
// 窗口可能处于最小化/隐藏状态(如任务栏右键「关闭窗口」),
168+
// 必须先恢复并聚焦,否则应用内对话框用户不可见,表现为“无法关闭”
169+
if (win.isMinimized()) win.restore();
170+
if (!win.isVisible()) win.show();
171+
win.focus();
167172
win.webContents.send('window:closing');
168173
}
169174
} else if (!syncBeforeQuitCompleted) {

‎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);

‎client/src/components/ui/ContextMenu.tsx‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
/**
22
* @ai-context: UI 基础组件(shadcn/radix 封装):ContextMenu。
3+
* 定位机制:Radix ContextMenu 的菜单锚点取自触发器真实收到的 contextmenu 事件坐标,
4+
* 因此挂载后向隐藏触发器派发携带 position 坐标的原生事件来打开菜单;
5+
* 绝不可强制传 open(锚点未初始化会导致菜单固定在屏幕左上角)。
36
*/
4-
import React, { useCallback } from 'react';
7+
import React, { useCallback, useEffect, useRef } from 'react';
58
import * as RadixContextMenu from '@radix-ui/react-context-menu';
69
import { motion } from 'framer-motion';
710
import { cn } from '@/lib/utils';
@@ -120,6 +123,8 @@ export const ContextMenu = <C,>({
120123
onSelect,
121124
onClose,
122125
}: ContextMenuProps<C>) => {
126+
const triggerRef = useRef<HTMLSpanElement>(null);
127+
123128
const handleSelect = useCallback(
124129
(itemKey: string) => {
125130
onSelect(itemKey, context);
@@ -128,10 +133,24 @@ export const ContextMenu = <C,>({
128133
[onSelect, context, onClose],
129134
);
130135

136+
// 向隐藏触发器派发带坐标的 contextmenu 事件,让 Radix 记录锚点并打开菜单;
137+
// position 变化时(菜单未关闭又右键另一目标)重新派发以重定位
138+
useEffect(() => {
139+
triggerRef.current?.dispatchEvent(new MouseEvent('contextmenu', {
140+
bubbles: true,
141+
cancelable: true,
142+
clientX: position.x,
143+
clientY: position.y,
144+
}));
145+
}, [position.x, position.y]);
146+
131147
return (
132-
<RadixContextMenu.Root open onOpenChange={(v) => { if (!v) onClose(); }}>
148+
<RadixContextMenu.Root onOpenChange={(v) => { if (!v) onClose(); }}>
133149
{/* Hidden trigger at target position */}
134150
<RadixContextMenu.Trigger
151+
ref={triggerRef}
152+
// 阻止合成事件冒泡到页面的 onContextMenu,避免重复触发 handleContextMenu
153+
onContextMenu={(e) => e.stopPropagation()}
135154
style={{
136155
position: 'fixed',
137156
left: position.x,
@@ -147,7 +166,7 @@ export const ContextMenu = <C,>({
147166

148167
<RadixContextMenu.Portal>
149168
<RadixContextMenu.Content
150-
avoidCollisions={false}
169+
collisionPadding={8}
151170
onCloseAutoFocus={(e: Event) => e.preventDefault()}
152171
className={cn(
153172
'z-[9999] bg-bg-elevated/90 backdrop-blur-2xl border border-border/50 rounded-kb-md shadow-xl py-1 min-w-[160px]',
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+
}

0 commit comments

Comments
 (0)