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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user