Skip to content

Commit e2e4b85

Browse files
committed
feat(dashboard): D5 热力图效率维度 + R3 结束仪式 + R9 成就证书
1 parent 76241a3 commit e2e4b85

14 files changed

Lines changed: 782 additions & 31 deletions

File tree

‎client/src/features/dashboard/components/HeatmapChart.tsx‎

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,25 +28,52 @@ function parseColor(v: string): [number, number, number] {
2828
return m ? [+m[1], +m[2], +m[3]] : [0, 0, 0];
2929
}
3030

31-
/** 线性插值两个 hex 颜色 */
32-
function lerp(a: [number, number, number], b: [number, number, number], t: number) {
33-
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
31+
/** RGB 三元组线性插值(供多段混色链使用,避免 string 中间态) */
32+
function mix(a: [number, number, number], b: [number, number, number], t: number): [number, number, number] {
33+
return [
34+
Math.round(a[0] + (b[0] - a[0]) * t),
35+
Math.round(a[1] + (b[1] - a[1]) * t),
36+
Math.round(a[2] + (b[2] - a[2]) * t),
37+
];
38+
}
39+
40+
/** RGB 三元组 → CSS 颜色串 */
41+
function toCss(c: [number, number, number]): string {
42+
return `rgb(${c[0]},${c[1]},${c[2]})`;
3443
}
3544

3645
const HeatmapChart: React.FC<Props> = ({ data, loading }) => {
37-
const [tip, setTip] = useState<{ day: string; hour: number; val: number; x: number; y: number } | null>(null);
46+
const [tip, setTip] = useState<{ day: string; hour: number; val: number; eff: number | null; peak: boolean; x: number; y: number } | null>(null);
3847

39-
const { max, bg, brand } = useMemo(() => {
48+
const { max, bg, brand, gold } = useMemo(() => {
4049
const mx = Math.max(...data.map((c) => c.value), 1);
4150
const s = getComputedStyle(document.documentElement);
42-
return { max: mx, bg: parseColor(s.getPropertyValue('--kb-bg-tertiary')), brand: parseColor(s.getPropertyValue('--kb-brand-500')) };
51+
return {
52+
max: mx,
53+
bg: parseColor(s.getPropertyValue('--kb-bg-tertiary')),
54+
brand: parseColor(s.getPropertyValue('--kb-brand-500')),
55+
gold: parseColor(s.getPropertyValue('--kb-accent-400') || 'rgb(251,191,36)'),
56+
};
57+
}, [data]);
58+
59+
// D5 效率维度:有效样本的完成率均值(0-1),无样本为 null
60+
const { maxEff, minEff } = useMemo(() => {
61+
const effs = data.map((c) => c.efficiency).filter((e): e is number => e != null);
62+
if (effs.length === 0) return { maxEff: 1, minEff: 0 };
63+
return { maxEff: Math.max(...effs, 1), minEff: Math.min(...effs, 0) };
4364
}, [data]);
4465

45-
const cellColor = useCallback((v: number) => lerp(bg, brand, v / max), [bg, brand, max]);
66+
// D5 单元格配色:亮度=分钟数(原语义),色相=效率(高→品牌金,低→灰蓝)
67+
const cellColor = useCallback((c: HeatmapCell) => {
68+
const base = mix(bg, brand, c.value / max);
69+
if (c.efficiency == null) return toCss(base);
70+
const t = maxEff === minEff ? 0.5 : (c.efficiency - minEff) / (maxEff - minEff);
71+
return toCss(mix(base, gold, Math.min(1, Math.max(0, t)) * 0.55));
72+
}, [bg, brand, gold, max, maxEff, minEff]);
4673

4774
const onEnter = useCallback((e: React.MouseEvent, c: HeatmapCell) => {
4875
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
49-
setTip({ day: DAYS[c.dayOfWeek], hour: c.hour, val: c.value, x: r.left + r.width / 2, y: r.top });
76+
setTip({ day: DAYS[c.dayOfWeek], hour: c.hour, val: c.value, eff: c.efficiency ?? null, peak: c.peak ?? false, x: r.left + r.width / 2, y: r.top });
5077
}, []);
5178
const onLeave = useCallback(() => setTip(null), []);
5279

@@ -75,7 +102,11 @@ const HeatmapChart: React.FC<Props> = ({ data, loading }) => {
75102
<div
76103
key={`${c.dayOfWeek}-${c.hour}`}
77104
className="h-[14px] rounded-[2px] cursor-pointer transition-opacity duration-150 hover:opacity-80"
78-
style={{ backgroundColor: cellColor(c.value) }}
105+
style={{
106+
backgroundColor: cellColor(c),
107+
// D5 黄金时段标注:高峰档格子加金色描边,突出个人高效窗口
108+
boxShadow: c.peak && c.value > 0 ? 'inset 0 0 0 1px rgba(251,191,36,0.55)' : undefined,
109+
}}
79110
onMouseEnter={(e) => onEnter(e, c)}
80111
onMouseLeave={onLeave}
81112
/>
@@ -84,6 +115,16 @@ const HeatmapChart: React.FC<Props> = ({ data, loading }) => {
84115
</div>
85116
</div>
86117

118+
{/* D5 图例:分钟数 + 黄金时段 */}
119+
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-text-tertiary">
120+
<span>颜色深浅 = 专注分钟数</span>
121+
<span>颜色偏金 = 完成率更高</span>
122+
<span className="flex items-center gap-1">
123+
<span className="inline-block w-3 h-3 rounded-[2px]" style={{ boxShadow: 'inset 0 0 0 1px rgba(251,191,36,0.55)', backgroundColor: 'rgba(251,191,36,0.15)' }} />
124+
黄金时段(个人高峰档)
125+
</span>
126+
</div>
127+
87128
{/* Tooltip */}
88129
{tip && (
89130
<div
@@ -92,6 +133,10 @@ const HeatmapChart: React.FC<Props> = ({ data, loading }) => {
92133
>
93134
<span className="text-text-secondary">{tip.day} {tip.hour}:00</span>
94135
<span className="text-text-primary font-medium ml-1.5">{tip.val}分钟</span>
136+
{tip.eff != null && (
137+
<span className="text-text-secondary ml-1.5">完成率 {Math.round(tip.eff * 100)}%</span>
138+
)}
139+
{tip.peak && <span className="text-amber-400 ml-1.5">✦ 黄金时段</span>}
95140
</div>
96141
)}
97142
</div>

‎client/src/features/dashboard/components/StartupRitual.tsx‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { RitualStepGoal } from './ritual/RitualStepGoal';
2222
import { RitualStepIntention } from './ritual/RitualStepIntention';
2323
import { RitualStepBreathing } from './ritual/RitualStepBreathing';
2424
import { RitualComplete } from './ritual/RitualComplete';
25+
import { ClosingCeremony } from './ritual/ClosingCeremony';
2526
import { RitualSkipMenu } from './ritual/RitualSkipMenu';
2627
import { RitualFooter } from './ritual/RitualFooter';
2728

@@ -59,6 +60,10 @@ export default function StartupRitual(props: Props) {
5960
const [triggerTime, setTriggerTime] = useState('');
6061
const [cycleLit, setCycleLit] = useState(false);
6162
const [done, setDone] = useState(false);
63+
// R3 结束仪式分支:RitualComplete 定格后追加 closingCeremony(总结+复习卡闭环)
64+
const [closing, setClosing] = useState(false);
65+
// 进入结束仪式时冻结仪式用时(不把用户在结束仪式的停留计入 durationMs)
66+
const finalDurationMsRef = useRef<number | null>(null);
6267
const [sound, setSound] = useState(soundOn);
6368
const cardRef = useRef<HTMLDivElement>(null);
6469

@@ -86,7 +91,7 @@ export default function StartupRitual(props: Props) {
8691
goal: goalText.trim() ? ({ text: goalText.trim(), tags: pickedTags } as MicroGoal) : undefined,
8792
masteryMark: mastery ?? undefined,
8893
intention,
89-
durationMs: machine.getElapsedMs(),
94+
durationMs: finalDurationMsRef.current ?? machine.getElapsedMs(),
9095
planVariant: machine.planVariant,
9196
};
9297
}, [goalText, pickedTags, mastery, machine, intentionIf, intentionThen, buildTriggerAt]);
@@ -96,6 +101,18 @@ export default function StartupRitual(props: Props) {
96101
machine.next();
97102
}, [machine]);
98103

104+
/** R3 结束仪式:RitualComplete 定格完成后进入 closing 分支(不直接落库) */
105+
const handleCompleteEnter = useCallback(() => {
106+
// 冻结仪式用时:不含用户在结束仪式上的停留时间
107+
finalDurationMsRef.current = machine.getElapsedMs();
108+
setClosing(true);
109+
}, [machine]);
110+
111+
/** 结束仪式收尾:页面层经 onComplete 落库 + 复习卡闭环 */
112+
const handleClosingClose = useCallback(() => {
113+
onComplete(buildOutcome());
114+
}, [onComplete, buildOutcome]);
115+
99116
const handlePickTag = useCallback((tag: QuickTag) => {
100117
setGoalText(tag.text);
101118
setPickedTags((prev) => (prev.includes(tag.text) ? prev : [...prev, tag.text]));
@@ -138,12 +155,22 @@ export default function StartupRitual(props: Props) {
138155
<BreathingProvider onPhaseChange={onPhaseChange} onCycleComplete={onCycleComplete}>
139156
<div className="relative px-8 pt-10 pb-8 flex flex-col gap-6">
140157
{done ? (
141-
<RitualComplete
142-
goal={buildOutcome().goal}
143-
masteryMark={mastery ?? undefined}
144-
streakDays={streakDays}
145-
onEnter={() => onComplete(buildOutcome())}
146-
/>
158+
closing ? (
159+
<ClosingCeremony
160+
goal={buildOutcome().goal}
161+
masteryMark={mastery ?? undefined}
162+
streakDays={streakDays}
163+
durationMs={machine.getElapsedMs()}
164+
onClose={handleClosingClose}
165+
/>
166+
) : (
167+
<RitualComplete
168+
goal={buildOutcome().goal}
169+
masteryMark={mastery ?? undefined}
170+
streakDays={streakDays}
171+
onEnter={handleCompleteEnter}
172+
/>
173+
)
147174
) : (
148175
<>
149176
{machine.currentStep === 'review' && (

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,26 @@
66
import { useEffect, useState } from 'react';
77
import { motion } from 'framer-motion';
88
import {
9-
Timer, Layers, Lightbulb, FileText, Flame, Trophy, Medal,
9+
Timer, Layers, Lightbulb, FileText, Flame, Trophy, Medal, Repeat, ListChecks, Award,
1010
type LucideIcon,
1111
} from 'lucide-react';
1212
import { cn } from '@/lib/utils';
1313
import { db } from '@/lib/storage/database';
1414
import { ACHIEVEMENT_DEFS } from '@/lib/achievements/definitions';
1515
import type { Achievement } from '@/types/models';
1616
import { useReducedMotion } from '@/hooks/useReducedMotion';
17+
import { useNavigate } from 'react-router-dom';
1718

18-
const ICON_MAP: Record<string, LucideIcon> = { Timer, Layers, Lightbulb, FileText, Flame, Trophy, Medal };
19+
const ICON_MAP: Record<string, LucideIcon> = { Timer, Layers, Lightbulb, FileText, Flame, Trophy, Medal, Repeat, ListChecks };
1920

2021
function formatDate(date: Date): string {
2122
const d = new Date(date);
2223
return `${d.getMonth() + 1}/${d.getDate()}`;
2324
}
2425

2526
export default function AnglerfishAchievements() {
27+
const navigate = useNavigate();
2628
const [unlockedMap, setUnlockedMap] = useState<Record<string, Achievement>>({});
27-
const [loading, setLoading] = useState(true);
2829
const prefersReduced = useReducedMotion();
2930

3031
useEffect(() => {
@@ -34,8 +35,7 @@ export default function AnglerfishAchievements() {
3435
const map: Record<string, Achievement> = {};
3536
achievements.forEach((a) => { map[a.key] = a; });
3637
setUnlockedMap(map);
37-
})
38-
.finally(() => setLoading(false));
38+
});
3939
}, []);
4040

4141
const unlockedCount = Object.keys(unlockedMap).length;
@@ -50,7 +50,17 @@ export default function AnglerfishAchievements() {
5050
{/* 标题 */}
5151
<div className="flex items-center justify-between mb-2">
5252
<h3 className="text-[10px] font-medium text-cyan-200/60">灯笼鱼成就</h3>
53-
<span className="text-[9px] text-amber-300/50">{unlockedCount}/{ACHIEVEMENT_DEFS.length}</span>
53+
<div className="flex items-center gap-2">
54+
<span className="text-[9px] text-amber-300/50">{unlockedCount}/{ACHIEVEMENT_DEFS.length}</span>
55+
{/* R9 成就证书入口:跳转打印页生成证书 */}
56+
<button
57+
onClick={() => navigate('/certificate')}
58+
className="flex items-center gap-0.5 text-[9px] text-cyan-200/50 hover:text-cyan-200 transition-colors"
59+
title="生成成就证书"
60+
>
61+
<Award className="w-3 h-3" strokeWidth={1.5} /> 证书
62+
</button>
63+
</div>
5464
</div>
5565

5666
{/* 成就网格 */}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* ClosingCeremony 组件测试 / Tests for the R3 closing ceremony branch
3+
*
4+
* @ai-context: 覆盖结束仪式契约——总结卡(目标/掌握/用时/连续天数)渲染、
5+
* 复习卡闭环三态文案(模糊/未掌握→已安排、已掌握→无需、未标记→引导)、
6+
* 开始学习按钮触发 onClose。
7+
*/
8+
import { describe, it, expect, vi, afterEach } from 'vitest';
9+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
10+
import { ClosingCeremony } from './ClosingCeremony';
11+
12+
afterEach(() => {
13+
cleanup();
14+
});
15+
16+
describe('ClosingCeremony (R3)', () => {
17+
it('should render summary card with goal, mastery, duration and streak', () => {
18+
render(
19+
<ClosingCeremony
20+
goal={{ text: '搞懂傅里叶变换', tags: [] }}
21+
masteryMark="fuzzy"
22+
streakDays={3}
23+
durationMs={125_000}
24+
onClose={() => {}}
25+
/>,
26+
);
27+
expect(screen.getByText(/今日仪式完成/)).toBeInTheDocument();
28+
expect(screen.getByText(/今日目标:搞懂傅里叶变换/)).toBeInTheDocument();
29+
expect(screen.getByText(/上次掌握度:模糊/)).toBeInTheDocument();
30+
expect(screen.getByText(/仪式用时:2 分 5 秒/)).toBeInTheDocument();
31+
expect(screen.getByText(/连续 3 天/)).toBeInTheDocument();
32+
});
33+
34+
it('should announce review card planned for fuzzy mark (closure loop)', () => {
35+
render(<ClosingCeremony masteryMark="fuzzy" streakDays={1} durationMs={1000} onClose={() => {}} />);
36+
expect(screen.getByText(/已为你安排 1 张复习卡/)).toBeInTheDocument();
37+
});
38+
39+
it('should announce review card planned for unmastered mark', () => {
40+
render(<ClosingCeremony masteryMark="unmastered" streakDays={1} durationMs={1000} onClose={() => {}} />);
41+
expect(screen.getByText(/已为你安排 1 张复习卡/)).toBeInTheDocument();
42+
});
43+
44+
it('should not plan review card when mastered', () => {
45+
render(<ClosingCeremony masteryMark="mastered" streakDays={1} durationMs={1000} onClose={() => {}} />);
46+
expect(screen.getByText(/已掌握的内容无需额外复习卡/)).toBeInTheDocument();
47+
});
48+
49+
it('should guide user when mastery not marked', () => {
50+
render(<ClosingCeremony streakDays={1} durationMs={1000} onClose={() => {}} />);
51+
expect(screen.getByText(/标记掌握度后可为模糊内容安排复习卡/)).toBeInTheDocument();
52+
});
53+
54+
it('should call onClose when start button clicked', () => {
55+
const onClose = vi.fn();
56+
render(<ClosingCeremony streakDays={1} durationMs={1000} onClose={onClose} />);
57+
fireEvent.click(screen.getByRole('button', { name: /开始学习/ }));
58+
expect(onClose).toHaveBeenCalledTimes(1);
59+
});
60+
});

0 commit comments

Comments
 (0)