Skip to content

Commit 7d76b6e

Browse files
committed
feat(dashboard): 主页三视图认知重构与双方案视觉系统
- 今日/成长/世界三视图架构:欢迎区固定首屏,视图懒挂载+常驻保留状态 - 双方案表面语言:深海毛玻璃发光 / 穹顶平面阴影,跟随主题可独立覆盖(设置页三选一+穹顶星云开关) - 3D 模块空间语义分环重排:行动/产出/扩展分组+左右对称心智地图(含位置快照测试) - 世界视图留存生态布局优化:成就墙 9 列网格、行高协调 - DashboardPage 922 行拆分为数据 hooks + 视图组件(单文件 ≤300 行规范)
1 parent 5e43dd8 commit 7d76b6e

21 files changed

Lines changed: 1860 additions & 899 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* DashboardCard 组件测试 — 双方案表面语言分支
3+
*
4+
* 验证 deep-sea(毛玻璃发光系)与 aurora-dome(平面阴影系)渲染出不同的 class 组合,
5+
* 保证「两套方案非换色」的结构性差异在卡片容器层成立。
6+
*
7+
* @ai-context: 覆盖 DashboardCard 双 scheme 表面样式分支。
8+
*/
9+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
10+
import { render, screen, cleanup } from '@testing-library/react';
11+
import { DashboardCard } from './DashboardCard';
12+
13+
// mock useHomeScheme:组件依赖 appSettingsStore(Dexie)与 useThemeStore,单测注入固定 scheme
14+
const mockUseHomeScheme = vi.fn();
15+
vi.mock('../hooks/useHomeScheme', () => ({
16+
useHomeScheme: () => mockUseHomeScheme(),
17+
}));
18+
19+
afterEach(() => {
20+
cleanup();
21+
vi.clearAllMocks();
22+
});
23+
24+
describe('DashboardCard(双方案表面语言)', () => {
25+
beforeEach(() => {
26+
mockUseHomeScheme.mockReturnValue({ scheme: 'deep-sea' });
27+
});
28+
29+
it('deep-sea 渲染毛玻璃系(backdrop-blur + 半透明底 + 细边框)', () => {
30+
// Arrange & Act
31+
render(<DashboardCard className="extra">内容</DashboardCard>);
32+
const card = screen.getByText('内容');
33+
34+
// Assert
35+
expect(card.className).toContain('backdrop-blur-sm');
36+
expect(card.className).toContain('bg-bg-elevated/30');
37+
expect(card.className).toContain('border-border/15');
38+
expect(card.className).not.toContain('shadow-kb-sm');
39+
expect(card.className).toContain('extra');
40+
});
41+
42+
it('aurora-dome 渲染平面系(不透明底 + 浅阴影 + 无 blur)', () => {
43+
// Arrange
44+
mockUseHomeScheme.mockReturnValue({ scheme: 'aurora-dome' });
45+
46+
// Act
47+
render(<DashboardCard>内容</DashboardCard>);
48+
const card = screen.getByText('内容');
49+
50+
// Assert
51+
expect(card.className).toContain('shadow-kb-sm');
52+
expect(card.className).toContain('bg-bg-elevated');
53+
expect(card.className).toContain('border-border/30');
54+
expect(card.className).not.toContain('backdrop-blur-sm');
55+
expect(card.className).not.toContain('bg-bg-elevated/30');
56+
});
57+
58+
it('可点击卡片 deep-sea 下带 hover 光晕与指针样式', () => {
59+
// Arrange & Act
60+
render(<DashboardCard onClick={() => {}}>按钮卡</DashboardCard>);
61+
const card = screen.getByText('按钮卡');
62+
63+
// Assert
64+
expect(card.className).toContain('cursor-pointer');
65+
expect(card.className).toContain('hover:bg-bg-elevated/50');
66+
expect(card.className).toMatch(/hover:shadow-\[0_0_24px/);
67+
});
68+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* DashboardCard — 三视图统一卡片容器(双方案表面语言)
3+
*
4+
* 尺寸固定化规范:圆角/内边距/图标尺寸由本组件统一,视图内不出现裸的 backdrop-blur/shadow 硬编码。
5+
* 双方案分支(结构性差异非换色):
6+
* - deep-sea(深海):毛玻璃 + 细边框 + hover 模块色边缘光晕
7+
* - aurora-dome(穹顶):不透明平面 + 浅阴影(hover 加深)+ 边框
8+
*
9+
* @ai-context: 首页统一卡片容器——双主题表面语言分支。
10+
*/
11+
import { cn } from '@/lib/utils';
12+
import { useHomeScheme } from '../hooks/useHomeScheme';
13+
import type { ReactNode, KeyboardEvent } from 'react';
14+
15+
interface DashboardCardProps {
16+
children: ReactNode;
17+
className?: string;
18+
/** hover 光晕/阴影使用的模块色(deep-sea 下生效),如 'pomodoro' */
19+
accent?: 'pomodoro' | 'note' | 'flashcard' | 'feynman' | 'brand' | 'default';
20+
onClick?: () => void;
21+
onKeyDown?: (e: KeyboardEvent) => void;
22+
role?: string;
23+
tabIndex?: number;
24+
'aria-label'?: string;
25+
}
26+
27+
const ACCENT_GLOW: Record<NonNullable<DashboardCardProps['accent']>, string> = {
28+
pomodoro: 'hover:shadow-[0_0_24px_-6px_rgba(249,115,22,0.45)]',
29+
note: 'hover:shadow-[0_0_24px_-6px_rgba(59,130,246,0.45)]',
30+
flashcard: 'hover:shadow-[0_0_24px_-6px_rgba(16,185,129,0.45)]',
31+
feynman: 'hover:shadow-[0_0_24px_-6px_rgba(139,92,246,0.45)]',
32+
brand: 'hover:shadow-[0_0_24px_-6px_rgba(99,102,241,0.45)]',
33+
default: 'hover:shadow-[0_0_24px_-6px_rgba(255,255,255,0.25)]',
34+
};
35+
36+
export function DashboardCard({
37+
children,
38+
className,
39+
accent = 'default',
40+
onClick,
41+
onKeyDown,
42+
role,
43+
tabIndex,
44+
'aria-label': ariaLabel,
45+
}: DashboardCardProps) {
46+
const { scheme } = useHomeScheme();
47+
48+
return (
49+
<div
50+
role={role}
51+
tabIndex={tabIndex}
52+
aria-label={ariaLabel}
53+
onClick={onClick}
54+
onKeyDown={onKeyDown}
55+
className={cn(
56+
'rounded-kb-xl transition-all duration-beat-x3',
57+
scheme === 'deep-sea'
58+
? cn(
59+
'border border-border/15 bg-bg-elevated/30 backdrop-blur-sm',
60+
onClick && 'cursor-pointer hover:bg-bg-elevated/50',
61+
ACCENT_GLOW[accent],
62+
)
63+
: cn(
64+
'border border-border/30 bg-bg-elevated shadow-kb-sm',
65+
onClick && 'cursor-pointer hover:shadow-kb-md',
66+
),
67+
className,
68+
)}
69+
>
70+
{children}
71+
</div>
72+
);
73+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* DashboardWelcome — 欢迎区(全局门面,固定首屏)
3+
*
4+
* 认知任务:回归奖赏(宪法第二条:久别返回的第一反馈永远是"欢迎回来"性质)+ 归属感。
5+
* 三视图共享:视图切换时欢迎区不动,减少视觉跳动(注意力残留最小化)。
6+
* StreakBubble 内嵌右上角(即时反馈,习惯循环的奖励端)。
7+
*
8+
* @ai-context: 首页全局欢迎区。
9+
*/
10+
import { Suspense, lazy } from 'react';
11+
import { motion } from 'framer-motion';
12+
import { Sparkles } from 'lucide-react';
13+
import { SPRING, fadeInUp } from '@/lib/animation/springConfig';
14+
import ModuleRitualHeader from '@/components/ui/ModuleRitualHeader';
15+
import { getTodayLabel } from '../utils/dashboardUtils';
16+
import type { StreakState } from '@/features/retention/types';
17+
18+
// 留存组件懒加载(首屏 bundle 控制)
19+
const StreakBubble = lazy(() => import('@/features/retention/components/StreakBubble').then(m => ({ default: m.StreakBubble })));
20+
21+
interface DashboardWelcomeProps {
22+
greetingText: string;
23+
streakDays: number;
24+
streakState: StreakState | null;
25+
}
26+
27+
export function DashboardWelcome({ greetingText, streakDays, streakState }: DashboardWelcomeProps) {
28+
return (
29+
<section className="relative w-full overflow-hidden">
30+
<motion.div
31+
className="relative max-w-[1100px] mx-auto px-6 pt-rhythm-xl pb-rhythm-md"
32+
initial="hidden"
33+
animate="visible"
34+
>
35+
{/* 连续打卡气泡:布局内嵌欢迎区右上(即时反馈) */}
36+
<div className="absolute top-6 right-6 z-10 hidden md:block">
37+
<Suspense fallback={null}>
38+
<StreakBubble streakState={streakState} />
39+
</Suspense>
40+
</div>
41+
42+
<motion.div className="mb-rhythm-md" {...fadeInUp}>
43+
<div className="flex items-start gap-3">
44+
<ModuleRitualHeader sealChar="星" sealColor="#6366F1" compact />
45+
<div>
46+
<h1 className="text-d2 font-semibold text-text-primary tracking-tight mb-2">
47+
{greetingText}
48+
</h1>
49+
<p className="text-b2 text-text-tertiary">{getTodayLabel()}</p>
50+
</div>
51+
</div>
52+
{streakDays > 0 && (
53+
<motion.span
54+
className="inline-flex items-center gap-1 mt-3 px-3 py-1 rounded-kb-full bg-brand-500/10 text-brand-500 text-c1 font-medium"
55+
initial={{ opacity: 0, scale: 0.8 }}
56+
animate={{ opacity: 1, scale: 1 }}
57+
transition={{ ...SPRING.bouncy, delay: 0.3 }}
58+
>
59+
<Sparkles className="w-3 h-3" /> 连续学习 {streakDays} 天
60+
</motion.span>
61+
)}
62+
</motion.div>
63+
</motion.div>
64+
</section>
65+
);
66+
}

‎client/src/features/dashboard/components/deep-sea/creatures/AnglerfishAchievements.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ export default function AnglerfishAchievements() {
6363
</div>
6464
</div>
6565

66-
{/* 成就网格 */}
67-
<div className="grid grid-cols-4 gap-2">
66+
{/* 成就网格:桌面 9 列(18 项排 2 行,横向徽章横幅);窄屏回退 4 列 */}
67+
<div className="grid grid-cols-4 md:grid-cols-9 gap-2">
6868
{ACHIEVEMENT_DEFS.map((def, i) => {
6969
const Icon = ICON_MAP[def.icon] ?? Timer;
7070
const unlocked = unlockedMap[def.key];
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* DashboardViewSwitcher — 三视图分段控制器(今日/成长/世界)
3+
*
4+
* 认知架构入口:一个视图 = 一个心理模式。切换即切换认知任务。
5+
* 双方案表面语言分支:deep-sea 毛玻璃胶囊 / aurora-dome 平面胶囊。
6+
* sticky 顶部常驻,保证切换入口随时可达。
7+
*
8+
* @ai-context: 首页三视图切换器。
9+
*/
10+
import { motion } from 'framer-motion';
11+
import { cn } from '@/lib/utils';
12+
import type { HomeScheme } from '../../hooks/useHomeScheme';
13+
14+
export type DashboardView = 'today' | 'growth' | 'world';
15+
16+
const VIEW_TABS: Array<{ id: DashboardView; label: string }> = [
17+
{ id: 'today', label: '今日' },
18+
{ id: 'growth', label: '成长' },
19+
{ id: 'world', label: '世界' },
20+
];
21+
22+
interface DashboardViewSwitcherProps {
23+
view: DashboardView;
24+
onChange: (v: DashboardView) => void;
25+
scheme: HomeScheme;
26+
}
27+
28+
export function DashboardViewSwitcher({ view, onChange, scheme }: DashboardViewSwitcherProps) {
29+
return (
30+
<div className="sticky top-0 z-20 max-w-[1100px] mx-auto px-6 pt-3 pb-1">
31+
<motion.div
32+
initial={{ opacity: 0, y: -8 }}
33+
animate={{ opacity: 1, y: 0 }}
34+
transition={{ duration: 0.3 }}
35+
role="tablist"
36+
aria-label="首页视图切换"
37+
className={cn(
38+
'flex items-center gap-1 p-1 w-fit mx-auto rounded-kb-lg border',
39+
scheme === 'deep-sea'
40+
? 'bg-bg-secondary/80 backdrop-blur-sm border-border/40'
41+
: 'bg-bg-elevated shadow-kb-sm border-border/30',
42+
)}
43+
>
44+
{VIEW_TABS.map((tab) => (
45+
<button
46+
key={tab.id}
47+
type="button"
48+
role="tab"
49+
aria-selected={view === tab.id}
50+
onClick={() => onChange(tab.id)}
51+
className={cn(
52+
'px-6 py-2 rounded-kb-md text-b2 font-medium',
53+
'transition-all duration-beat-x2 hover:scale-[1.02] active:scale-[0.98]',
54+
view === tab.id
55+
? 'bg-bg-elevated text-text-primary shadow-kb-sm border border-border/30'
56+
: 'text-text-tertiary hover:text-text-secondary border border-transparent',
57+
)}
58+
>
59+
{tab.label}
60+
</button>
61+
))}
62+
</motion.div>
63+
</div>
64+
);
65+
}

0 commit comments

Comments
 (0)