Skip to content

Commit 06a668b

Browse files
committed
feat: gamification, exercises, visualizations, dashboard, showcase + security fixes
Game System: - XP engine with level calculation and streak tracking - 10 achievement badges with unlock animations (Confetti + BadgeUnlock) - XP bar, level badge, streak flame in sidebar and dashboard - Event bus pattern for decoupled gamification triggers Inline Code Exercises: - 6 interactive exercises across key lessons (3-1 to 6-3) - Textarea editor with live code preview (Blob URL sandbox) - Hint system, completion detection, and auto-save Visualizations (animated concept demos): - VibeCodingFlow: 6-step animated workflow diagram - TokenStream: token-by-token AI code generation animation - DomTree: HTML to DOM tree construction animation - BoxModelVisualizer: interactive CSS box model explorer AI Enhancements: - Socratic teaching mode (guided discovery vs direct answer toggle) - AI code review panel in playground - /api/review endpoint with provider multiplexing Project Showcase: - Save playground outputs to local project gallery - Export dialog with download, copy, and deploy guides (GitHub Pages, Netlify) - /showcase route with project card grid Learning Dashboard (/dashboard): - SVG GitHub-style activity heatmap (52 weeks) - SVG skill radar chart (6-axis polygon) - Animated stat cards with count-up animation Adaptive Learning: - Recommendation engine based on quiz scores and progress - Smart suggestions on home page and lesson pages PWA Support: - manifest.json with theme-color and app icons - Service worker with cache-first (static) + network-first (content) strategies - Install prompt banner - Offline fallback for cached pages UI Polish: - 8 new CSS keyframe animations (bounce-in, slide-in-right/left, pop-in, etc.) - Page transition, skeleton loading, tooltip, hover-lift utility classes - AnimatedCounter and Confetti canvas components - Micro-interaction classes (hover-lift, hover-glow, btn-ripple) Security fixes: - SSRF: replace startsWith URL validation with new URL().hostname check - Sandbox: replace document.write with Blob URL for code preview - Auth: add Bearer token check to /api/review route - Rate limiter: probabilistic stale entry eviction - Remove dead/unsafe code (require() in render, import *, dynamic Tailwind)
1 parent 875d308 commit 06a668b

52 files changed

Lines changed: 4295 additions & 89 deletions

Some content is hidden

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

app/api/agent/route.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest } from "next/server";
2-
import { SYSTEM_PROMPT } from "@/lib/system-prompt";
2+
import { SYSTEM_PROMPT, SOCRATIC_SYSTEM_PROMPT } from "@/lib/system-prompt";
33

44
// ---- 速率限制 ----
55
const RATE_WINDOW_MS = 60_000; // 1 分钟窗口
@@ -15,23 +15,30 @@ function checkRateLimit(ip: string): boolean {
1515
}
1616
if (entry.count >= MAX_REQUESTS_PER_WINDOW) return false;
1717
entry.count++;
18+
// Periodic cleanup of stale entries
19+
if (Math.random() < 0.01) {
20+
for (const [key, val] of rateMap) {
21+
if (now >= val.resetAt) rateMap.delete(key);
22+
}
23+
}
1824
return true;
1925
}
2026

2127
// ---- 允许的 baseURL 白名单(防 SSRF) ----
22-
const ALLOWED_BASE_URLS = [
23-
"https://api.deepseek.com",
24-
"https://api.deepseek.com/v1",
25-
"https://api.openai.com",
26-
"https://api.openai.com/v1",
27-
"https://api.anthropic.com",
28-
];
28+
const ALLOWED_HOSTNAMES = new Set([
29+
"api.deepseek.com",
30+
"api.openai.com",
31+
"api.anthropic.com",
32+
]);
2933

3034
function validateBaseURL(url: string): boolean {
31-
// 仅允许 HTTPS
32-
if (!url.startsWith("https://")) return false;
33-
// 检查是否在白名单中(以白名单前缀匹配)
34-
return ALLOWED_BASE_URLS.some((allowed) => url.startsWith(allowed));
35+
try {
36+
const parsed = new URL(url);
37+
if (parsed.protocol !== "https:") return false;
38+
return ALLOWED_HOSTNAMES.has(parsed.hostname);
39+
} catch {
40+
return false;
41+
}
3542
}
3643

3744
// ---- 消息验证 ----
@@ -70,13 +77,14 @@ async function handleOpenAICompatible(
7077
baseURL: string,
7178
model: string,
7279
messages: { role: string; content: string }[],
80+
systemPrompt: string,
7381
) {
7482
const url = baseURL.replace(/\/+$/, "") + "/chat/completions";
7583

7684
const body = JSON.stringify({
7785
model,
7886
messages: [
79-
{ role: "system", content: SYSTEM_PROMPT },
87+
{ role: "system", content: systemPrompt },
8088
...messages.map((m) => ({ role: m.role, content: m.content })),
8189
],
8290
stream: true,
@@ -196,13 +204,15 @@ export async function POST(req: NextRequest) {
196204

197205
const body = await req.json();
198206
const messages = validateMessages(body.messages);
207+
const mode = body.mode === 'socratic' ? 'socratic' : 'direct';
208+
const activePrompt = mode === 'socratic' ? SOCRATIC_SYSTEM_PROMPT : SYSTEM_PROMPT;
199209

200210
if (provider === "openai-compatible" || provider === "openai") {
201211
const openaiBaseURL =
202212
provider === "openai"
203213
? "https://api.openai.com/v1"
204214
: baseURL;
205-
return handleOpenAICompatible(apiKey, openaiBaseURL, model, messages);
215+
return handleOpenAICompatible(apiKey, openaiBaseURL, model, messages, activePrompt);
206216
}
207217

208218
// Anthropic
@@ -213,7 +223,7 @@ export async function POST(req: NextRequest) {
213223

214224
const result = streamText({
215225
model: languageModel,
216-
system: SYSTEM_PROMPT,
226+
system: activePrompt,
217227
messages: messages.map((m) => ({
218228
role: m.role as "user" | "assistant",
219229
content: m.content,

app/api/review/route.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { NextRequest } from 'next/server';
2+
3+
const SYSTEM_REVIEW_PROMPT = `你是一个友善的代码审阅老师,帮零基础的人看代码。
4+
5+
请按以下格式回复(用中文):
6+
7+
大白话解释:用 2-3 句最直白的话,说这段代码干了什么。要让完全不懂编程的人也能听懂。
8+
9+
做得好的地方:
10+
- 列出 2-3 个优点
11+
12+
可以改进的地方:
13+
- 列出 1-3 个改进建议,每个建议用最简单的话说清楚"改什么 + 怎么改"
14+
15+
注意:
16+
- 语气要友善鼓励
17+
- 不要用技术术语,非用不可时要括号解释
18+
- 每个点一句话说清楚`;
19+
20+
const RATE_WINDOW_MS = 60_000;
21+
const MAX_REQUESTS_PER_WINDOW = 20;
22+
const rateMap = new Map<string, { count: number; resetAt: number }>();
23+
24+
function checkRateLimit(ip: string): boolean {
25+
const now = Date.now();
26+
const entry = rateMap.get(ip);
27+
if (!entry || now >= entry.resetAt) {
28+
rateMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
29+
return true;
30+
}
31+
if (entry.count >= MAX_REQUESTS_PER_WINDOW) return false;
32+
entry.count++;
33+
if (Math.random() < 0.01) {
34+
for (const [key, val] of rateMap) {
35+
if (now >= val.resetAt) rateMap.delete(key);
36+
}
37+
}
38+
return true;
39+
}
40+
41+
const ALLOWED_HOSTNAMES = new Set([
42+
'api.deepseek.com',
43+
'api.openai.com',
44+
'api.anthropic.com',
45+
]);
46+
47+
function validateBaseURL(url: string): boolean {
48+
try {
49+
const parsed = new URL(url);
50+
if (parsed.protocol !== 'https:') return false;
51+
return ALLOWED_HOSTNAMES.has(parsed.hostname);
52+
} catch {
53+
return false;
54+
}
55+
}
56+
57+
export async function POST(req: NextRequest) {
58+
// API 认证(与 agent 路由一致的选项)
59+
const authToken = process.env.AI_API_AUTH_TOKEN;
60+
if (authToken) {
61+
const auth = req.headers.get("Authorization");
62+
if (!auth || auth !== `Bearer ${authToken}`) {
63+
return new Response(
64+
JSON.stringify({ error: "Unauthorized" }),
65+
{ status: 401, headers: { "Content-Type": "application/json" } },
66+
);
67+
}
68+
}
69+
70+
const ip =
71+
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
72+
req.headers.get('x-real-ip') ||
73+
'unknown';
74+
if (!checkRateLimit(ip)) {
75+
return new Response(JSON.stringify({ error: '请求过于频繁,请稍后再试' }), {
76+
status: 429,
77+
headers: { 'Content-Type': 'application/json' },
78+
});
79+
}
80+
81+
try {
82+
const provider = process.env.AI_PROVIDER || 'openai-compatible';
83+
const model = process.env.AI_MODEL || 'deepseek-chat';
84+
const apiKey = process.env.AI_API_KEY;
85+
const baseURL = process.env.AI_BASE_URL || 'https://api.deepseek.com/v1';
86+
87+
if (!apiKey) {
88+
return new Response(JSON.stringify({ error: 'AI_API_KEY 未配置' }), {
89+
status: 500,
90+
headers: { 'Content-Type': 'application/json' },
91+
});
92+
}
93+
94+
if (provider === 'openai-compatible' && !validateBaseURL(baseURL)) {
95+
return new Response(JSON.stringify({ error: 'AI_BASE_URL 不在允许列表中' }), {
96+
status: 400,
97+
headers: { 'Content-Type': 'application/json' },
98+
});
99+
}
100+
101+
const body = await req.json();
102+
const code = body.code?.slice(0, 20000);
103+
if (!code || typeof code !== 'string') {
104+
return new Response(JSON.stringify({ error: 'code 参数缺失' }), {
105+
status: 400,
106+
headers: { 'Content-Type': 'application/json' },
107+
});
108+
}
109+
110+
const reviewPrompt = `${SYSTEM_REVIEW_PROMPT}\n\n请审阅以下代码:\n\n\`\`\`html\n${code}\n\`\`\``;
111+
112+
if (provider === 'openai-compatible' || provider === 'openai') {
113+
const openaiBaseURL = provider === 'openai' ? 'https://api.openai.com/v1' : baseURL;
114+
const url = openaiBaseURL.replace(/\/+$/, '') + '/chat/completions';
115+
const response = await fetch(url, {
116+
method: 'POST',
117+
headers: {
118+
'Content-Type': 'application/json',
119+
Authorization: `Bearer ${apiKey}`,
120+
},
121+
body: JSON.stringify({
122+
model,
123+
messages: [{ role: 'user', content: reviewPrompt }],
124+
stream: false,
125+
max_tokens: 1024,
126+
}),
127+
});
128+
if (!response.ok) {
129+
const text = await response.text();
130+
throw new Error(`API 请求失败 (${response.status}): ${text.slice(0, 200)}`);
131+
}
132+
const data = await response.json();
133+
const content = data.choices?.[0]?.message?.content || '';
134+
return new Response(content, {
135+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
136+
});
137+
}
138+
139+
// Anthropic
140+
const { generateText } = await import('ai');
141+
const { createAnthropic } = await import('@ai-sdk/anthropic');
142+
const anthropic = createAnthropic({ apiKey });
143+
const languageModel = anthropic(model);
144+
const result = await generateText({
145+
model: languageModel,
146+
system: SYSTEM_REVIEW_PROMPT,
147+
prompt: `请审阅以下代码:\n\n\`\`\`html\n${code}\n\`\`\``,
148+
maxOutputTokens: 1024,
149+
});
150+
151+
return new Response(result.text, {
152+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
153+
});
154+
} catch (error) {
155+
console.error('Review API error:', error);
156+
return new Response(JSON.stringify({ error: '审阅请求失败' }), {
157+
status: 500,
158+
headers: { 'Content-Type': 'application/json' },
159+
});
160+
}
161+
}

app/app-shell.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use client';
2+
3+
import { useState, useCallback } from 'react';
4+
import { usePathname } from 'next/navigation';
5+
import GamificationStatus from '@/components/gamification/GamificationStatus';
6+
import BadgeUnlock from '@/components/gamification/BadgeUnlock';
7+
import PWAPrompt from '@/components/ui/PWAPrompt';
8+
import { loadProgress, saveProgress } from '@/lib/progress';
9+
import { usePWA } from '@/hooks/usePWA';
10+
11+
export default function AppShell({ children }: { children: React.ReactNode }) {
12+
const pathname = usePathname();
13+
const isLessonPage = pathname.startsWith('/lesson/');
14+
const [queuedBadges, setQueuedBadges] = useState<string[]>([]);
15+
const { installPrompt, isInstalled, promptInstall, dismissPrompt } = usePWA();
16+
const [pwaDismissed, setPwaDismissed] = useState(false);
17+
18+
// Queued badge handling
19+
const handleBadgeUnlock = useCallback((badgeId: string) => {
20+
setQueuedBadges((prev) => [...prev, badgeId]);
21+
}, []);
22+
23+
const handleDismissBadge = useCallback(() => {
24+
setQueuedBadges((prev) => prev.slice(1));
25+
}, []);
26+
27+
const currentBadge = queuedBadges[0] || null;
28+
29+
return (
30+
<>
31+
{!isLessonPage && (
32+
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-40 w-full max-w-[400px] px-4">
33+
<GamificationStatus onBadgeUnlock={handleBadgeUnlock} />
34+
</div>
35+
)}
36+
{children}
37+
{currentBadge && (
38+
<BadgeUnlock badgeId={currentBadge} onDismiss={handleDismissBadge} />
39+
)}
40+
{installPrompt && !isInstalled && !pwaDismissed && (
41+
<PWAPrompt
42+
onInstall={() => { promptInstall(); setPwaDismissed(true); }}
43+
onDismiss={() => { dismissPrompt(); setPwaDismissed(true); }}
44+
/>
45+
)}
46+
</>
47+
);
48+
}

app/dashboard/DashboardClient.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import { ArrowLeft, Trophy } from 'lucide-react';
5+
import StatsGrid from '@/components/dashboard/StatsGrid';
6+
import Heatmap from '@/components/dashboard/Heatmap';
7+
import SkillRadar from '@/components/dashboard/SkillRadar';
8+
import GamificationStatus from '@/components/gamification/GamificationStatus';
9+
10+
export default function DashboardClient() {
11+
return (
12+
<div className="min-h-screen bg-surface">
13+
<div className="max-w-[1000px] mx-auto px-6 py-12 md:py-16">
14+
{/* Back link */}
15+
<Link
16+
href="/"
17+
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition-colors mb-8 group"
18+
>
19+
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
20+
返回首页
21+
</Link>
22+
23+
{/* Header */}
24+
<div className="mb-10">
25+
<div className="decorative-line mb-4" />
26+
<h1 className="font-display text-3xl md:text-4xl font-bold">学习数据</h1>
27+
<p className="text-muted mt-3 text-lg">
28+
你的学习旅程,每一分努力都看得见
29+
</p>
30+
</div>
31+
32+
{/* Gamification bar */}
33+
<div className="mb-8">
34+
<GamificationStatus />
35+
</div>
36+
37+
{/* Stats */}
38+
<div className="mb-8">
39+
<StatsGrid />
40+
</div>
41+
42+
{/* Charts */}
43+
<div className="grid md:grid-cols-2 gap-6">
44+
<Heatmap />
45+
<SkillRadar />
46+
</div>
47+
48+
{/* Bottom CTA */}
49+
<div className="mt-16 pt-8 border-t border-edge text-center">
50+
<Link
51+
href="/showcase"
52+
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white rounded-xl font-semibold hover:shadow-lg active:scale-95 transition-all"
53+
>
54+
<Trophy className="w-4 h-4" />
55+
查看我的作品
56+
</Link>
57+
</div>
58+
</div>
59+
</div>
60+
);
61+
}

app/dashboard/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Metadata } from 'next';
2+
import DashboardClient from './DashboardClient';
3+
4+
export const metadata: Metadata = {
5+
title: '学习数据 - 梦夜的编程课',
6+
};
7+
8+
export default function DashboardPage() {
9+
return <DashboardClient />;
10+
}

0 commit comments

Comments
 (0)