P8 平台轻量化改造 + SSG修复 + 编程导师 + 文档完善

- Prisma: Tool 模型加 affiliateLink;免费用户沙盒 10→5 次/日
- 后端: Tools API + /admin/tools CRUD 5 端点;Practices 完整模块
- 导航: 主菜单隐藏企业版/社区(URL 可访问)
- 首页: 重定位为 AI 工具指南;新增精选工具区块;Feature 重写
- 工具页: affiliateLink 绿色推荐 Badge
- SSG 修复: config.ts 构建时直连 localhost:4000,页面 108→127
- 沙盒: 新增编程导师场景(苏格拉底教学法)
- 练习系统: Practices 多场景练习(含结构化评分)
- 技能广场: 6 个付费 Skill(标题大师/回款助手等)
- 管理后台: Models/Posts/Practices CRUD 页面
- 文档: README + progress.md 全面更新;AGENTS.md 同步定位
- 清理: .env.example 移除;tsbuildinfo gitignore
This commit is contained in:
yuzhiran-dev
2026-06-18 18:14:07 +08:00
parent fb8152401b
commit 0f215d2aad
103 changed files with 5240 additions and 778 deletions
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
import { useAuth } from '@/lib/auth-context';
import { apiFetch } from '@/lib/auth';
interface Practice {
id: number;
title: string;
scenario: string;
description: string;
difficulty: string;
category: string;
attemptCount: number;
sortOrder: number;
createdAt: string;
}
interface Submission {
id: number;
questionId: number;
score: number | null;
status: string;
submittedAt: string;
}
const difficultyColors: Record<string, string> = {
BEGINNER: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
INTERMEDIATE: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
ADVANCED: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
};
export default function PracticesPage() {
const t = useT();
const { isLoggedIn } = useAuth();
const [practices, setPractices] = useState<Practice[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [category, setCategory] = useState('');
const [difficulty, setDifficulty] = useState('');
const [search, setSearch] = useState('');
const [submissions, setSubmissions] = useState<Record<number, Submission>>({});
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/practices`).then(r => r.json()),
fetch(`${API_BASE}/practices/categories`).then(r => r.json()),
]).then(([data, cats]) => {
setPractices(data.items || []);
setCategories(cats || []);
}).finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!isLoggedIn) return;
apiFetch('/practices/submissions?limit=100')
.then(r => r.json())
.then(data => {
const map: Record<number, Submission> = {};
(data.items || []).forEach((s: Submission) => { map[s.questionId] = s; });
setSubmissions(map);
})
.catch(() => {});
}, [isLoggedIn]);
useEffect(() => {
const params = new URLSearchParams();
if (category) params.set('category', category);
if (difficulty) params.set('difficulty', difficulty);
if (search) params.set('search', search);
fetch(`${API_BASE}/practices?${params}`)
.then(r => r.json())
.then(data => setPractices(data.items || []));
}, [category, difficulty, search]);
const difficulties = [
{ id: 'BEGINNER', name: t.practices.beginner },
{ id: 'INTERMEDIATE', name: t.practices.intermediate },
{ id: 'ADVANCED', name: t.practices.advanced },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.practices.title}</h1>
<p className="mt-2 text-muted-foreground">{t.practices.desc}</p>
</div>
<div className="flex flex-wrap gap-3 mb-8">
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder={t.common.search}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground w-48" />
<select value={category} onChange={e => setCategory(e.target.value)}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
<option value="">{t.practices.allCategories}</option>
{categories.map(c => <option key={c} value={c}>{(t.practices.categories as any)[c] || c}</option>)}
</select>
<select value={difficulty} onChange={e => setDifficulty(e.target.value)}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
<option value="">{t.practices.allDifficulties}</option>
{difficulties.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-44 rounded-2xl" />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{practices.map(p => {
const sub = submissions[p.id];
return (
<Link key={p.id} href={`/practices/${p.id}`}
className="bg-card rounded-2xl border border-border p-6 hover:shadow-md transition-all hover:-translate-y-0.5 group">
<div className="mb-2 flex items-start justify-between gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${difficultyColors[p.difficulty] || ''}`}>
{(t.practices as any)[p.difficulty.toLowerCase()] || p.difficulty}
</span>
{sub && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
sub.status === 'SCORED' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}>
{sub.status === 'SCORED' ? t.practices.scoreRange.replace('{score}', String(sub.score)).replace('{max}', '100') : (t.practices.status as any)[sub.status]}
</span>
)}
</div>
<h3 className="font-semibold text-foreground group-hover:text-brand-600 transition-colors mb-1">{p.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{p.description}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{(t.practices.categories as any)[p.category] || p.category}</span>
<span>·</span>
<span>{p.attemptCount} </span>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}